diff --git a/apps/hub/package.json b/apps/hub/package.json index b48f5066..d2c4e570 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -18,6 +18,7 @@ "@corbits-builder/coder": "workspace:*", "@corbits-builder/coordinator": "workspace:*", "@corbits-builder/designer": "workspace:*", + "@corbits-builder/presenter": "workspace:*", "@corbits-builder/reviewer": "workspace:*", "@corbits-builder/schemas": "workspace:*", "@corbits-builder/writer": "workspace:*", diff --git a/apps/hub/src/artifacts/approval-package.ts b/apps/hub/src/artifacts/approval-package.ts index c66d10e6..4cb646b0 100644 --- a/apps/hub/src/artifacts/approval-package.ts +++ b/apps/hub/src/artifacts/approval-package.ts @@ -45,6 +45,18 @@ export type StoredPackageSource = { kind: string; version: number; sha256: string; + approved?: boolean; +}; + +export type SealedPackageAssignment = { + audience: string; + decisionNeeded?: string; + sources: readonly { + artifactId: string; + kind: string; + version: number; + sha256: string; + }[]; }; export type StoredSourceReader = ( @@ -118,11 +130,61 @@ async function verifySourcePin( message: `${source.artifactId}@${String(source.version)}`, }, ]); + if (stored.approved === false) + throw new ProjectError(422, "unapproved_package_source", [ + { + path: "manifest.sources", + message: `${source.kind}@${String(source.version)}`, + }, + ]); +} + +function verifySealedAssignment( + bundle: ApprovalPackageBundle, + sealed: SealedPackageAssignment, +): void { + if (bundle.manifest.audience.trim() !== sealed.audience.trim()) { + throw new ProjectError(422, "package_assignment_mismatch", [ + { + path: "manifest.audience", + message: `expected ${sealed.audience}`, + }, + ]); + } + if ( + sealed.decisionNeeded !== undefined && + bundle.manifest.decisionNeeded.trim() !== sealed.decisionNeeded.trim() + ) { + throw new ProjectError(422, "package_assignment_mismatch", [ + { + path: "manifest.decisionNeeded", + message: `expected ${sealed.decisionNeeded}`, + }, + ]); + } + const actualKeys = bundle.manifest.sources.map( + (s) => `${s.artifactId}:${s.kind}:${String(s.version)}:${s.sha256}`, + ); + const expectedKeys = sealed.sources.map( + (s) => `${s.artifactId}:${s.kind}:${String(s.version)}:${s.sha256}`, + ); + if ( + actualKeys.length !== expectedKeys.length || + actualKeys.some((key, idx) => key !== expectedKeys[idx]) + ) { + throw new ProjectError(422, "package_assignment_mismatch", [ + { + path: "manifest.sources", + message: "sources do not match the approved hand-off selection", + }, + ]); + } } export async function validateApprovalPackage( input: string | ApprovalPackageBundle, readSource: StoredSourceReader, + sealedAssignment?: SealedPackageAssignment, ): Promise { const bundle = typeof input === "string" ? parseApprovalPackageBundle(input) : input; @@ -133,6 +195,9 @@ export async function validateApprovalPackage( ]); } scanApprovalPackageForSecrets(bundle); + if (sealedAssignment !== undefined) { + verifySealedAssignment(bundle, sealedAssignment); + } for (const source of bundle.manifest.sources) await verifySourcePin(source, readSource); return bundle; @@ -141,6 +206,11 @@ export async function validateApprovalPackage( export function readProjectPackageSource( db: ArtifactDb, tenantId: string, + isApproved?: ( + artifactId: string, + kind: string, + version: number, + ) => Promise, ): StoredSourceReader { return async (artifactId, version) => { if (!UUID_RE.test(artifactId)) return undefined; @@ -148,10 +218,15 @@ export function readProjectPackageSource( if (!row || row.tenantId !== tenantId) return undefined; const stored = await getArtifactVersion(db, artifactId, version); if (!stored?.contentSha256) return undefined; + const approved = + isApproved === undefined + ? true + : await isApproved(artifactId, row.kind, stored.version); return { kind: row.kind, version: stored.version, sha256: stored.contentSha256, + approved, }; }; } diff --git a/apps/hub/src/artifacts/package-tools.ts b/apps/hub/src/artifacts/package-tools.ts new file mode 100644 index 00000000..24cd515c --- /dev/null +++ b/apps/hub/src/artifacts/package-tools.ts @@ -0,0 +1,657 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { type } from "arktype"; +import { defineTool, type BaseEnv } from "@intx/agent/authoring"; +import { HUB_CREDENTIAL_HANDLE } from "@intx/types/credential-delivery"; +import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; +import { Deck, DeckError, validateDeck } from "@corbits/slidedeck"; +import { defaultPackageTitle } from "../config/personas.ts"; +import { resolveWorkflowProjectScope } from "../workflows/project-scope.ts"; +import { workflowHttpJson } from "../workflows/http-json.ts"; +import { + DECK_JSON, + DECK_PDF, + DECK_PPTX, + checkDeckInWorkspace, + writeDeckToWorkspace, +} from "./deck-tools.ts"; +import { failure, HubArtifactReceipt } from "./tools.ts"; + +export { DECK_JSON, DECK_PDF, DECK_PPTX }; + +export interface PackageToolEnv extends BaseEnv { + toolCwd: string; + capabilities: RuntimeCapabilities; +} + +export const DECK_CHECK = join(".builder", "deck-check.json"); +export const PACKAGE_ASSIGNMENT = join(".builder", "package-assignment.json"); + +export const PackageSourcePin = type({ + "+": "ignore", + artifactId: "string > 0", + kind: "string > 0", + title: "string > 0", + version: "number.integer > 0", + sha256: /^[a-f0-9]{64}$/, +}); +export type PackageSourcePin = typeof PackageSourcePin.infer; + +export const PackageAssignmentFile = type({ + "+": "ignore", + "sealed?": "boolean", + audience: "string > 0", + decisionNeeded: "string > 0", + "personaId?": "string > 0", + "personaLabel?": "string > 0", + "tone?": "string", + "length?": "string", + "title?": "string", + "summary?": "string", + sources: PackageSourcePin.array().atLeastLength(1), +}); +export type PackageAssignmentFile = typeof PackageAssignmentFile.infer; + +const HexSha256 = /^[a-f0-9]{64}$/; + +const DeckCheckRecord = type({ + "+": "ignore", + passed: "boolean", + sha256: HexSha256, + pptxSha256: HexSha256, + pdfSha256: HexSha256, + "checklist?": "string", +}); + +const FilePackageArgs = type({ + "+": "ignore", + "title?": "string > 0", + "summary?": "string > 0", + "audience?": "string > 0", + "decisionNeeded?": "string > 0", + "tone?": "string > 0", + "length?": "string > 0", + "sources?": PackageSourcePin.array().atLeastLength(1), + "assignment?": PackageAssignmentFile, +}); + +const WriteDeckArgs = type({ + "+": "ignore", + deck: Deck, + "assignment?": PackageAssignmentFile, +}); + +type DeckCheckState = { + passed: boolean; + sha256: string; + pptxSha256: string; + pdfSha256: string; + checklist: string; +}; + +const deckChecks = new Map(); + +function sha256Hex(input: string | Uint8Array): string { + return createHash("sha256").update(input).digest("hex"); +} + +function assignmentsMatch( + expected: PackageAssignmentFile, + candidate: { + audience?: string; + decisionNeeded?: string; + sources?: readonly PackageSourcePin[]; + }, +): boolean { + if ( + candidate.audience !== undefined && + candidate.audience.trim() !== expected.audience.trim() + ) { + return false; + } + if ( + candidate.decisionNeeded !== undefined && + candidate.decisionNeeded.trim() !== expected.decisionNeeded.trim() + ) { + return false; + } + if (candidate.sources !== undefined) { + const expectedKeys = expected.sources.map( + (s) => `${s.artifactId}:${s.kind}:${String(s.version)}:${s.sha256}`, + ); + const candidateKeys = candidate.sources.map( + (s) => `${s.artifactId}:${s.kind}:${String(s.version)}:${s.sha256}`, + ); + if ( + expectedKeys.length !== candidateKeys.length || + expectedKeys.some((key, index) => key !== candidateKeys[index]) + ) { + return false; + } + } + return true; +} + +export async function writePackageAssignment( + workspace: string, + assignment: PackageAssignmentFile, +): Promise { + const checked = PackageAssignmentFile.assert({ + ...assignment, + sealed: true, + }); + const target = join(workspace, PACKAGE_ASSIGNMENT); + const existingText = await readTextIfPresent(target); + if (existingText !== undefined) { + const existing = PackageAssignmentFile(JSON.parse(existingText)); + if ( + !(existing instanceof type.errors) && + existing.sealed === true && + !assignmentsMatch(existing, checked) + ) { + throw new Error( + "invalid_package_assignment: sealed hand-off assignment cannot be overwritten with a different audience or sources", + ); + } + } + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, JSON.stringify(checked, null, 2), "utf8"); +} + +export async function recordDeckCheck( + workspace: string, + result: { + passed: boolean; + checklist: string; + sha256?: string; + pptxSha256?: string; + pdfSha256?: string; + }, +): Promise { + const root = await realpath(workspace); + let sha256 = result.sha256; + if (!sha256) { + const rawDeck = await readFile(join(root, DECK_JSON), "utf8"); + sha256 = sha256Hex(rawDeck); + } + const pptxBytes = await readBytesIfPresent(join(root, DECK_PPTX)); + const pdfBytes = await readBytesIfPresent(join(root, DECK_PDF)); + const pptxSha256 = + result.pptxSha256 ?? (pptxBytes ? sha256Hex(pptxBytes) : "0".repeat(64)); + const pdfSha256 = + result.pdfSha256 ?? (pdfBytes ? sha256Hex(pdfBytes) : "0".repeat(64)); + const state: DeckCheckState = { + passed: result.passed, + sha256, + pptxSha256, + pdfSha256, + checklist: result.checklist, + }; + deckChecks.set(root, state); + const target = join(root, DECK_CHECK); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, JSON.stringify(state, null, 2), "utf8"); +} + +async function readTextIfPresent(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +async function readBytesIfPresent(path: string): Promise { + try { + return await readFile(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +async function verifyDeckCheckPassed( + workspace: string, + deckJsonText: string, + pptxBytes: Uint8Array, + pdfBytes: Uint8Array, +): Promise<{ ok: true } | { ok: false; message: string }> { + const currentSha = sha256Hex(deckJsonText); + const currentPptxSha = sha256Hex(pptxBytes); + const currentPdfSha = sha256Hex(pdfBytes); + const inMemory = deckChecks.get(workspace); + const diskText = await readTextIfPresent(join(workspace, DECK_CHECK)); + let diskRecord: typeof DeckCheckRecord.infer | undefined; + if (diskText !== undefined) { + try { + const parsed = DeckCheckRecord(JSON.parse(diskText)); + if (parsed instanceof type.errors) { + return { + ok: false, + message: `deck_check_failed: invalid ${DECK_CHECK} (${parsed.summary})`, + }; + } + diskRecord = parsed; + } catch (error) { + return { + ok: false, + message: `deck_check_failed: invalid ${DECK_CHECK} (${error instanceof Error ? error.message : String(error)})`, + }; + } + } + const record = diskRecord ?? inMemory; + if (!record) { + return { + ok: false, + message: + "deck_check_failed: check_deck has not run on the current deck yet", + }; + } + if (!record.passed) { + const suffix = record.checklist ? `\n${record.checklist}` : ""; + return { + ok: false, + message: `deck_check_failed: the deck is filed only once check_deck passes.${suffix}`, + }; + } + if (record.sha256 !== currentSha) { + return { + ok: false, + message: + "deck_check_failed: .builder/deck.json changed after check_deck passed; run check_deck again", + }; + } + if (record.pptxSha256 !== currentPptxSha) { + return { + ok: false, + message: + "deck_check_failed: .builder/deck.pptx changed after check_deck passed; run check_deck again", + }; + } + if (record.pdfSha256 !== currentPdfSha) { + return { + ok: false, + message: + "deck_check_failed: .builder/deck.pdf changed after check_deck passed; run check_deck again", + }; + } + return { ok: true }; +} + +async function resolveAssignment( + workspace: string, + args: typeof FilePackageArgs.infer, +): Promise< + | { ok: true; assignment: PackageAssignmentFile } + | { ok: false; message: string } +> { + const diskText = await readTextIfPresent(join(workspace, PACKAGE_ASSIGNMENT)); + if (diskText !== undefined) { + let diskParsed: unknown; + try { + diskParsed = JSON.parse(diskText); + } catch (error) { + return { + ok: false, + message: `invalid_package_assignment: ${error instanceof Error ? error.message : String(error)}`, + }; + } + const sealed = PackageAssignmentFile(diskParsed); + if (sealed instanceof type.errors) { + return { + ok: false, + message: `invalid_package_assignment: ${sealed.summary}`, + }; + } + if ( + !assignmentsMatch(sealed, { + ...(args.audience !== undefined ? { audience: args.audience } : {}), + ...(args.decisionNeeded !== undefined + ? { decisionNeeded: args.decisionNeeded } + : {}), + ...(args.sources !== undefined ? { sources: args.sources } : {}), + }) || + (args.assignment !== undefined && + !assignmentsMatch(sealed, args.assignment)) + ) { + return { + ok: false, + message: + "invalid_package_assignment: audience, decisionNeeded and sources must match the sealed hand-off assignment", + }; + } + return { + ok: true, + assignment: { + ...sealed, + ...(args.title ? { title: args.title } : {}), + ...(args.summary ? { summary: args.summary } : {}), + }, + }; + } + const merged = { + ...(args.assignment ?? undefined), + ...(args.audience ? { audience: args.audience } : {}), + ...(args.decisionNeeded ? { decisionNeeded: args.decisionNeeded } : {}), + ...(args.tone ? { tone: args.tone } : {}), + ...(args.length ? { length: args.length } : {}), + ...(args.title ? { title: args.title } : {}), + ...(args.summary ? { summary: args.summary } : {}), + ...(args.sources ? { sources: args.sources } : {}), + }; + const checked = PackageAssignmentFile(merged); + if (checked instanceof type.errors) { + return { + ok: false, + message: `invalid_package_assignment: ${checked.summary}`, + }; + } + return { ok: true, assignment: checked }; +} + +async function runWriteDeck( + workspace: string, + callId: string, + rawArgs: unknown, +) { + const input = WriteDeckArgs(rawArgs); + if (input instanceof type.errors) { + return { + callId, + content: `invalid_deck: ${input.summary}`, + isError: true, + }; + } + try { + if (input.assignment) { + await writePackageAssignment(workspace, input.assignment); + } + const written = await writeDeckToWorkspace(workspace, input.deck); + return { + callId, + content: JSON.stringify({ + slides: written.slideCount, + deckPath: DECK_JSON, + pptxPath: DECK_PPTX, + pdfPath: DECK_PDF, + passed: written.passed, + issues: written.issues, + }), + ...(written.passed ? {} : { isError: true }), + }; + } catch (error) { + if (error instanceof DeckError) { + return { + callId, + content: `invalid_deck: ${error.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`, + isError: true, + }; + } + if ( + error instanceof Error && + error.message.startsWith("invalid_package_assignment:") + ) { + return { + callId, + content: error.message, + isError: true, + }; + } + throw error; + } +} + +async function runCheckDeck(workspace: string, callId: string) { + const deckText = await readTextIfPresent(join(workspace, DECK_JSON)); + const pptxBytes = await readBytesIfPresent(join(workspace, DECK_PPTX)); + const pdfBytes = await readBytesIfPresent(join(workspace, DECK_PDF)); + if (!deckText || !pptxBytes?.byteLength || !pdfBytes?.byteLength) { + return { + callId, + content: + "deck_check_failed: .builder/deck.json, .builder/deck.pptx and .builder/deck.pdf must be written first", + isError: true, + }; + } + const sha256 = sha256Hex(deckText); + const pptxSha256 = sha256Hex(pptxBytes); + const pdfSha256 = sha256Hex(pdfBytes); + let checked: Awaited>; + try { + checked = await checkDeckInWorkspace(workspace); + } catch (error) { + return { + callId, + content: `deck_check_failed: invalid JSON in ${DECK_JSON} (${error instanceof Error ? error.message : String(error)})`, + isError: true, + }; + } + if (!checked.passed) { + const checklist = checked.issues + .map((issue) => `${issue.path}: ${issue.message}`) + .join("\n"); + await recordDeckCheck(workspace, { + passed: false, + sha256, + pptxSha256, + pdfSha256, + checklist, + }); + return { + callId, + content: `deck_check_failed:\n${checklist}`, + isError: true, + }; + } + const checklist = `check passed: verified ${DECK_JSON}, ${DECK_PPTX} and ${DECK_PDF}.`; + await recordDeckCheck(workspace, { + passed: true, + sha256, + pptxSha256, + pdfSha256, + checklist, + }); + return { callId, content: checklist }; +} + +function deriveDeckTitle( + deck: typeof Deck.infer, + assignment: PackageAssignmentFile, +): string { + if (assignment.title?.trim()) return assignment.title.trim(); + const firstSlide = deck.slides[0]; + if (firstSlide && "title" in firstSlide && firstSlide.title.trim()) { + return firstSlide.title.trim(); + } + const label = assignment.personaLabel ?? assignment.audience; + return defaultPackageTitle(label); +} + +export function createPackageTools() { + const definitions = [ + { + name: "write_deck", + description: + "Write the slide deck to .builder/deck.json, render .builder/deck.pptx and .builder/deck.pdf, and optionally record .builder/package-assignment.json.", + inputSchema: { + type: "object", + properties: { + deck: Deck.toJsonSchema({ fallback: { default: (ctx) => ctx.base } }), + assignment: { ...PackageAssignmentFile.toJsonSchema() }, + }, + required: ["deck"], + additionalProperties: false, + }, + }, + { + name: "check_deck", + description: + "Check .builder/deck.json, .builder/deck.pptx and .builder/deck.pdf for structural and visual validity before filing the approval package.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + }, + { + name: "file_package", + description: + "File the checked deck (.builder/deck.json, .builder/deck.pptx, .builder/deck.pdf) and .builder/package-assignment.json as the next version of the project's approval_package artifact. Refuses with deck_check_failed unless check_deck passed on the current deck.", + inputSchema: { ...FilePackageArgs.toJsonSchema() }, + }, + ]; + return defineTool({ + id: "@corbits-builder/artifacts/package-tools", + credentials: [{ handle: HUB_CREDENTIAL_HANDLE }], + requires: ["toolCwd", "capabilities"], + definitions: [ + { name: "write_deck" }, + { name: "check_deck" }, + { name: "file_package" }, + ], + factory: (env) => ({ + definitions, + run: async (call, signal) => { + try { + const workspace = await realpath(env.toolCwd); + if (call.name === "write_deck") { + return await runWriteDeck(workspace, call.id, call.arguments); + } + if (call.name === "check_deck") { + return await runCheckDeck(workspace, call.id); + } + if (call.name !== "file_package") { + return { + callId: call.id, + content: "unknown_package_tool", + isError: true, + }; + } + const rawArgs = + call.arguments && typeof call.arguments === "object" + ? call.arguments + : { title: undefined }; + const args = FilePackageArgs(rawArgs); + if (args instanceof type.errors) { + return { + callId: call.id, + content: `invalid_arguments: ${args.summary}`, + isError: true, + }; + } + const scope = await resolveWorkflowProjectScope( + env.capabilities, + "presenter", + signal, + ); + const deckText = await readTextIfPresent(join(workspace, DECK_JSON)); + const pptxBytes = await readBytesIfPresent( + join(workspace, DECK_PPTX), + ); + const pdfBytes = await readBytesIfPresent(join(workspace, DECK_PDF)); + if (!deckText || !pptxBytes?.byteLength || !pdfBytes?.byteLength) { + return { + callId: call.id, + content: + "deck_check_failed: .builder/deck.json, .builder/deck.pptx and .builder/deck.pdf must exist before filing", + isError: true, + }; + } + const checkStatus = await verifyDeckCheckPassed( + workspace, + deckText, + pptxBytes, + pdfBytes, + ); + if (!checkStatus.ok) { + return { + callId: call.id, + content: checkStatus.message, + isError: true, + }; + } + let parsedDeck: unknown; + try { + parsedDeck = JSON.parse(deckText); + } catch (error) { + return { + callId: call.id, + content: `deck_check_failed: invalid JSON in ${DECK_JSON} (${error instanceof Error ? error.message : String(error)})`, + isError: true, + }; + } + const validated = validateDeck(parsedDeck); + if (!validated.ok) { + return { + callId: call.id, + content: `deck_check_failed: ${validated.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`, + isError: true, + }; + } + const resolved = await resolveAssignment(workspace, args); + if (!resolved.ok) { + return { + callId: call.id, + content: resolved.message, + isError: true, + }; + } + const { assignment } = resolved; + const pptxBase64 = pptxBytes.toString("base64"); + const pdfBase64 = pdfBytes.toString("base64"); + const sources = assignment.sources.map((source) => ({ + artifactId: source.artifactId, + kind: source.kind, + title: source.title, + version: source.version, + sha256: source.sha256, + })); + const payloadContent = JSON.stringify({ + manifest: { + audience: assignment.audience, + decisionNeeded: assignment.decisionNeeded, + tone: assignment.tone?.trim() + ? assignment.tone.trim() + : "concise", + length: assignment.length?.trim() + ? assignment.length.trim() + : "short", + sources, + }, + deck: validated.deck, + pptxBase64, + pdfBase64, + }); + const title = deriveDeckTitle(validated.deck, assignment).slice( + 0, + 512, + ); + const projectPath = + "/api/tenants/" + + encodeURIComponent(scope.tenantId) + + "/projects/" + + encodeURIComponent(scope.projectId); + const { artifact } = HubArtifactReceipt.assert( + await workflowHttpJson({ + capabilities: env.capabilities, + handle: HUB_CREDENTIAL_HANDLE, + path: projectPath + "/artifacts", + body: { + kind: "approval_package", + title, + content: payloadContent, + }, + maxResponseBytes: 16384, + signal, + }), + ); + return { callId: call.id, content: JSON.stringify(artifact) }; + } catch (error) { + return failure(call.id, error); + } + }, + }), + }); +} diff --git a/apps/hub/src/artifacts/routes.ts b/apps/hub/src/artifacts/routes.ts index c5eaedf6..71fb2dec 100644 --- a/apps/hub/src/artifacts/routes.ts +++ b/apps/hub/src/artifacts/routes.ts @@ -429,6 +429,15 @@ export function mountArtifactRoutes( ); }, ); + app.post("/:projectId/presenter-assignments", async (c) => { + return c.json( + await service.recordPresenterAssignment( + actorOf(c), + c.req.param("projectId"), + await body(c), + ), + ); + }); app.get( "/:projectId/acceptance", describeRoute({ diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts index dd88aa3b..95da3984 100644 --- a/apps/hub/src/artifacts/service.ts +++ b/apps/hub/src/artifacts/service.ts @@ -92,6 +92,7 @@ import { designConcept, project, projectCoordinator, + settingsRecord, } from "../db/schema.ts"; import type { AgentActivityTracker } from "./activity.ts"; import type { Actor } from "../lib/actor.ts"; @@ -131,6 +132,7 @@ import { import { designChosenNote } from "../config/design.ts"; import { loadSettings } from "../settings/policy.ts"; import { + agentDecidesApprovals, preferenceGuidance, resolvePreferences, } from "../config/preferences.ts"; @@ -1344,15 +1346,84 @@ export function createArtifactService( throw new ProjectError(403, "artifact_write_refused", { kind: input.kind, }); - if (input.kind === "approval_package") - await validateApprovalPackage( - input.content, - readProjectPackageSource(db, actor.tenantId), - ); const [active] = await rawDB .select({ kind: principal.kind, refId: principal.refId }) .from(principal) .where(eq(principal.id, actor.principalId)); + if (input.kind === "approval_package") { + const settings = await loadSettings(rawDB, actor.tenantId, projectId); + const preferences = resolvePreferences(settings.preferences.value); + let sealed: + | { + audience: string; + decisionNeeded?: string; + sources: { + artifactId: string; + kind: string; + version: number; + sha256: string; + }[]; + } + | undefined; + if (active?.kind === "workflow" && active.refId) { + const [row] = await rawDB + .select({ fields: settingsRecord.fields }) + .from(settingsRecord) + .where( + and( + eq(settingsRecord.tenantId, actor.tenantId), + eq(settingsRecord.scopeKey, `presenter:${active.refId}`), + ), + ); + const rawAssignment = + row && typeof row.fields === "object" && row.fields !== null + ? (row.fields as unknown as Record)["assignment"] + : undefined; + if ( + typeof rawAssignment === "object" && + rawAssignment !== null && + "audience" in rawAssignment && + typeof rawAssignment.audience === "string" && + "sources" in rawAssignment && + Array.isArray(rawAssignment.sources) + ) { + sealed = { + audience: rawAssignment.audience, + ...("decisionNeeded" in rawAssignment && + typeof rawAssignment.decisionNeeded === "string" + ? { decisionNeeded: rawAssignment.decisionNeeded } + : {}), + sources: rawAssignment.sources as { + artifactId: string; + kind: string; + version: number; + sha256: string; + }[], + }; + } else { + throw new ProjectError( + 403, + "approval_package_assignment_mismatch", + { runId: active.refId }, + ); + } + } + await validateApprovalPackage( + input.content, + readProjectPackageSource( + db, + actor.tenantId, + async (artifactId, kind, version) => { + if (kind !== "design" && agentDecidesApprovals(preferences)) + return true; + return ( + (await approvedVersion(actor.tenantId, artifactId)) === version + ); + }, + ), + sealed, + ); + } // Each document version states its own open questions, so a revision never inherits the ones // its answers settled. const metadata = @@ -1813,6 +1884,100 @@ export function createArtifactService( }; }, + async recordPresenterAssignment( + actor: Actor, + projectId: string, + raw: unknown, + ) { + const PresenterAssignmentRecord = type({ + "+": "ignore", + runId: "string > 0", + assignment: { + "+": "ignore", + audience: "string > 0", + decisionNeeded: "string > 0", + "tone?": "string", + "length?": "string", + sources: type({ + "+": "ignore", + artifactId: "string > 0", + kind: "string > 0", + title: "string > 0", + version: "number.integer > 0", + sha256: /^[a-f0-9]{64}$/, + }) + .array() + .atLeastLength(1), + }, + }); + const input = PresenterAssignmentRecord(raw); + if (input instanceof type.errors) + throw new ProjectError(400, "invalid_presenter_assignment", { + issues: input.summary, + }); + await requireProject(actor, projectId, "manage"); + const settings = await loadSettings(rawDB, actor.tenantId, projectId); + const preferences = resolvePreferences(settings.preferences.value); + const readSource = readProjectPackageSource( + db, + actor.tenantId, + async (artifactId, kind, version) => { + if (kind !== "design" && agentDecidesApprovals(preferences)) + return true; + return ( + (await approvedVersion(actor.tenantId, artifactId)) === version + ); + }, + ); + for (const source of input.assignment.sources) { + const stored = await readSource(source.artifactId, source.version); + if ( + !stored || + stored.kind !== source.kind || + stored.version !== source.version || + stored.sha256 !== source.sha256 + ) { + throw new ProjectError(422, "invalid_package_source", [ + { + path: "assignment.sources", + message: `${source.artifactId}@${String(source.version)}`, + }, + ]); + } + if (stored.approved === false) { + throw new ProjectError(422, "unapproved_package_source", [ + { + path: "assignment.sources", + message: `${source.kind}@${String(source.version)}`, + }, + ]); + } + } + const sealedAssignment = { + ...input.assignment, + sealed: true as const, + }; + await rawDB + .insert(settingsRecord) + .values({ + tenantId: actor.tenantId, + scopeKey: `presenter:${input.runId}`, + projectId, + fields: { + assignment: sealedAssignment, + } as unknown as typeof settingsRecord.$inferInsert.fields, + }) + .onConflictDoUpdate({ + target: [settingsRecord.tenantId, settingsRecord.scopeKey], + set: { + fields: { + assignment: sealedAssignment, + } as unknown as typeof settingsRecord.$inferInsert.fields, + }, + }); + return { runId: input.runId, assignment: sealedAssignment }; + }, + // Called by an asking tool after its native approval resolved. The hub // binds the newest approved row for that tool and records only the refs it // covers whose hashes match storage; an approval is recorded once, so a diff --git a/apps/hub/src/artifacts/tools.ts b/apps/hub/src/artifacts/tools.ts index 065c60b2..a821173c 100644 --- a/apps/hub/src/artifacts/tools.ts +++ b/apps/hub/src/artifacts/tools.ts @@ -88,6 +88,7 @@ const DEFINITIONS_BY_ROLE: Record< reviewer: [readDefinition], coder: [readDefinition], designer: [readDefinition], + presenter: [readDefinition], }; export function createArtifactTools( diff --git a/apps/hub/src/auth/agent-token-bridge.ts b/apps/hub/src/auth/agent-token-bridge.ts index 50df0203..90cf38ad 100644 --- a/apps/hub/src/auth/agent-token-bridge.ts +++ b/apps/hub/src/auth/agent-token-bridge.ts @@ -299,6 +299,7 @@ export function createAgentTokenBridge( ? installed.coderAnyPackage : installed.coder, designer: installed.designer, + ...(installed.presenter ? { presenter: installed.presenter } : {}), }, openNetwork: opensNetwork(preferences.value), build: await buildLayout(), @@ -358,7 +359,7 @@ export function createAgentTokenBridge( ((c.req.method === "GET" && /^(|\/audit|\/artifacts(?:\/[^/]+)?)$/.test(suffix)) || (c.req.method === "POST" && - /^(\/mutations|\/artifacts|\/artifact-approvals|\/design-concepts)$/.test( + /^(\/mutations|\/artifacts|\/artifact-approvals|\/design-concepts|\/presenter-assignments)$/.test( suffix, ))); const deployRoute = diff --git a/apps/hub/src/config/models.ts b/apps/hub/src/config/models.ts index 44f2c312..1a4ee100 100644 --- a/apps/hub/src/config/models.ts +++ b/apps/hub/src/config/models.ts @@ -265,6 +265,14 @@ export const MODEL_ROLES: readonly Readonly<{ workflow: "reviewer", limits: "document", }, + { + id: "presenter", + label: "Presenter", + hint: "Builds slide decks and approval packages for an audience.", + tier: "medium", + workflow: "presenter", + limits: "presenter", + }, ]; export const EFFORTS: readonly Readonly<{ id: Effort; label: string }>[] = [ diff --git a/apps/hub/src/config/personas.ts b/apps/hub/src/config/personas.ts new file mode 100644 index 00000000..291147f8 --- /dev/null +++ b/apps/hub/src/config/personas.ts @@ -0,0 +1,258 @@ +export const AUDIENCE_PERSONA_IDS = [ + "ceo", + "cto", + "investor", + "client", + "end_user", +] as const; + +export type AudiencePersonaId = (typeof AUDIENCE_PERSONA_IDS)[number]; + +export type PersonaDocumentKind = + | "problem_brief" + | "constraints" + | "approaches" + | "product_requirements" + | "build_plan"; + +export type AudiencePersona = Readonly<{ + id: AudiencePersonaId; + label: string; + cares: readonly string[]; + defaultDocuments: readonly PersonaDocumentKind[]; + length: string; + tone: string; +}>; + +// The starting audience personas the coordinator offers when a person asks to share or present a +// project. Each pairs what that audience cares about with the documents and deck shape that answer it. +export const AUDIENCE_PERSONAS: readonly AudiencePersona[] = [ + { + id: "ceo", + label: "CEO", + cares: ["business outcome", "strategic fit", "risks", "resource ask"], + defaultDocuments: ["problem_brief", "approaches", "build_plan"], + length: "6-8 slides", + tone: "Executive and direct: lead with the business outcome, strategic fit, key risks and the resource ask.", + }, + { + id: "cto", + label: "CTO", + cares: [ + "architecture", + "constraints", + "security", + "technical trade-offs", + "build plan", + ], + defaultDocuments: [ + "problem_brief", + "constraints", + "approaches", + "build_plan", + ], + length: "8-12 slides", + tone: "Technical and rigorous: cover the architecture, constraints, security boundaries, technical trade-offs and phased build plan.", + }, + { + id: "investor", + label: "Investor", + cares: [ + "problem size", + "differentiation", + "user value", + "traction roadmap", + ], + defaultDocuments: ["problem_brief", "approaches", "product_requirements"], + length: "8-10 slides", + tone: "Crisp and evidence-led: frame the problem size, differentiation, user value and traction roadmap.", + }, + { + id: "client", + label: "Client", + cares: [ + "capabilities", + "workflow fit", + "delivery timeline", + "sign-off scope", + ], + defaultDocuments: ["problem_brief", "product_requirements", "build_plan"], + length: "8-10 slides", + tone: "Clear and collaborative: walk through capabilities, workflow fit, delivery timeline and sign-off scope.", + }, + { + id: "end_user", + label: "End user", + cares: ["day-to-day experience", "screens", "key workflows"], + defaultDocuments: ["problem_brief", "product_requirements"], + length: "5-7 slides", + tone: "Practical and approachable: show the day-to-day experience, screens and key workflows in plain language.", + }, +]; + +function normalizePersonaKey(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[\s-]+/g, "_"); +} + +export function findAudiencePersona( + audience: string, +): AudiencePersona | undefined { + const key = normalizePersonaKey(audience); + return AUDIENCE_PERSONAS.find( + (entry) => entry.id === key || normalizePersonaKey(entry.label) === key, + ); +} + +export function resolveAudiencePersona(audience: string): AudiencePersona { + const matched = findAudiencePersona(audience); + if (matched) return matched; + return { + id: "client", + label: audience.trim(), + cares: ["decision needed", "key outcomes", "scope and delivery"], + defaultDocuments: ["problem_brief", "product_requirements", "build_plan"], + length: "6-10 slides", + tone: "Clear, concrete and tailored to what this audience needs to decide.", + }; +} + +export function formatPersonaGuidance(persona: AudiencePersona): string { + return [ + `Audience persona: ${persona.label} (${persona.id})`, + `What they care about: ${persona.cares.join(", ")}`, + `Default documents: ${persona.defaultDocuments.join(", ")}`, + `Recommended length: ${persona.length}`, + `Recommended tone: ${persona.tone}`, + ].join("\n"); +} + +export function formatPersonasCatalog(): string { + return AUDIENCE_PERSONAS.map( + (persona) => + `- ${persona.label} (${persona.id}): cares about ${persona.cares.join(", ")}; default documents: ${persona.defaultDocuments.join(", ")}; length: ${persona.length}; tone: ${persona.tone}`, + ).join("\n"); +} + +export type PresenterSourceRef = Readonly<{ + artifactId: string; + kind: string; + title: string; + version: number; + sha256: string; +}>; + +export type PresenterSourceDocument = PresenterSourceRef & + Readonly<{ + content: string; + }>; + +export type PresenterDesignTokens = PresenterSourceRef & + Readonly<{ + designMd: string; + stylesCss?: string; + }>; + +export type PackageAssignment = Readonly<{ + sealed: true; + projectId: string; + audience: string; + personaId: AudiencePersonaId; + personaLabel: string; + decisionNeeded: string; + tone: string; + length: string; + instruction: string; + sources: readonly PresenterSourceRef[]; +}>; + +export type PresenterAssignmentInput = Readonly<{ + projectId: string; + audience: string; + decisionNeeded: string; + tone: string; + length: string; + instruction: string; + documents: readonly PresenterSourceDocument[]; + design?: PresenterDesignTokens; +}>; + +export function buildPackageAssignment( + input: PresenterAssignmentInput, +): PackageAssignment { + const persona = resolveAudiencePersona(input.audience); + const documentRefs: PresenterSourceRef[] = input.documents.map((doc) => ({ + artifactId: doc.artifactId, + kind: doc.kind, + title: doc.title, + version: doc.version, + sha256: doc.sha256, + })); + const sources: PresenterSourceRef[] = input.design + ? [ + ...documentRefs, + { + artifactId: input.design.artifactId, + kind: input.design.kind, + title: input.design.title, + version: input.design.version, + sha256: input.design.sha256, + }, + ] + : documentRefs; + return { + sealed: true, + projectId: input.projectId, + audience: input.audience.trim(), + personaId: persona.id, + personaLabel: persona.label, + decisionNeeded: input.decisionNeeded.trim(), + tone: input.tone.trim(), + length: input.length.trim(), + instruction: input.instruction.trim(), + sources, + }; +} + +export function defaultPackageTitle(label: string): string { + return `Approval package for ${label}`; +} + +export function presenterAssignmentMessage( + input: PresenterAssignmentInput, +): string { + const persona = resolveAudiencePersona(input.audience); + const assignment = buildPackageAssignment(input); + const sections: string[] = [ + "# Presenter Assignment", + formatPersonaGuidance(persona), + [ + `Chosen audience: ${assignment.audience}`, + `Decision needed: ${assignment.decisionNeeded}`, + `Chosen tone: ${assignment.tone}`, + `Chosen length: ${assignment.length}`, + `Coordinator instruction: ${assignment.instruction}`, + ].join("\n"), + `Pinned package assignment (pass this object as the \`assignment\` argument to \`write_deck\` or \`file_package\`):\n${JSON.stringify(assignment, null, 2)}`, + ]; + if (input.design) { + const designLines = [ + `## Project Design Tokens (${input.design.title}, version ${String(input.design.version)}, sha256 ${input.design.sha256})`, + input.design.designMd, + ]; + if (input.design.stylesCss) { + designLines.push(`### styles.css\n${input.design.stylesCss}`); + } + sections.push(designLines.join("\n\n")); + } + const docsSection = input.documents + .map( + (doc) => + `## Selected Document: ${doc.title} (kind: ${doc.kind}, artifactId: ${doc.artifactId}, version: ${String(doc.version)}, sha256: ${doc.sha256})\n\n${doc.content}`, + ) + .join("\n\n"); + sections.push(docsSection); + return sections.join("\n\n"); +} diff --git a/apps/hub/src/config/preferences.ts b/apps/hub/src/config/preferences.ts index d47ffbb0..c52bdfde 100644 --- a/apps/hub/src/config/preferences.ts +++ b/apps/hub/src/config/preferences.ts @@ -23,6 +23,7 @@ const ALL_ROLES = [ "designer", "coder", "reviewer", + "presenter", ] as const; // How Builder works with a person. Each entry is one setting in Settings, copied onto a diff --git a/apps/hub/src/config/project-status.ts b/apps/hub/src/config/project-status.ts index 3d5da263..56ca4f2e 100644 --- a/apps/hub/src/config/project-status.ts +++ b/apps/hub/src/config/project-status.ts @@ -7,6 +7,7 @@ const ROLE: Record = { reviewer: "a reviewer", designer: "the designer", coder: "the app builder", + presenter: "the presenter", }; const capital = (text: string) => text.charAt(0).toUpperCase() + text.slice(1); diff --git a/apps/hub/src/config/specialist-ended.ts b/apps/hub/src/config/specialist-ended.ts index 5e7de973..b37fe0d3 100644 --- a/apps/hub/src/config/specialist-ended.ts +++ b/apps/hub/src/config/specialist-ended.ts @@ -27,6 +27,10 @@ const PERSON_LINES: Record> = { failed: "The designer stopped before finishing.", cancelled: "The design was stopped.", }, + presenter: { + failed: "The presenter stopped before finishing.", + cancelled: "The presentation was stopped.", + }, }; const TASKS: Record string> = { @@ -35,6 +39,7 @@ const TASKS: Record string> = { `The reviewer checking the ${kind ?? "assigned"} document`, coder: () => "The coder building the app", designer: () => "The designer working on the app's design", + presenter: () => "The presenter building the approval package", }; export function specialistEndedMessage(ended: { diff --git a/apps/hub/src/inference/model-limits.ts b/apps/hub/src/inference/model-limits.ts index d5d54d99..774218b8 100644 --- a/apps/hub/src/inference/model-limits.ts +++ b/apps/hub/src/inference/model-limits.ts @@ -5,7 +5,12 @@ import type { CustomModel } from "@corbits-builder/schemas/models"; -export type AgentRole = "coordinator" | "document" | "coding" | "design"; +export type AgentRole = + | "coordinator" + | "document" + | "coding" + | "design" + | "presenter"; type Effort = "low" | "medium" | "high" | "max"; @@ -33,6 +38,7 @@ const sharedRoles: Record = { document: { maxOutputTokens: 32768 }, coding: { maxOutputTokens: 65536 }, design: { maxOutputTokens: 32768 }, + presenter: { maxOutputTokens: 32768 }, }; // context_window of each model in the Codex CLI catalog, openai/codex@b1e72963c3 @@ -138,6 +144,7 @@ export function customModelLimits(model: CustomModel): ModelLimits { document: budget, coding: budget, design: budget, + presenter: budget, }, }; } diff --git a/apps/hub/src/projects/deploy-presenter.test.ts b/apps/hub/src/projects/deploy-presenter.test.ts new file mode 100644 index 00000000..b05bb560 --- /dev/null +++ b/apps/hub/src/projects/deploy-presenter.test.ts @@ -0,0 +1,505 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; +import { + AUDIENCE_PERSONAS, + findAudiencePersona, + formatPersonaGuidance, + presenterAssignmentMessage, +} from "../config/personas.ts"; +import { + createPackageTools, + DECK_CHECK, + DECK_JSON, + DECK_PDF, + writePackageAssignment, +} from "../artifacts/package-tools.ts"; +import { createDeployTools } from "./deploy-tools.ts"; + +const deployment = { + source: { + kind: "asset" as const, + assetId: "asset", + package: { format: "tarball" as const }, + }, + entry: "entry", + pin: "@corbits-builder/presenter@1.0.0", + sourceOfferingIds: ["offering"], + defaultSourceOfferingId: "offering", +}; + +test("AUDIENCE_PERSONAS defines ceo, cto, investor, client and end_user with their default documents", () => { + expect(AUDIENCE_PERSONAS.map((persona) => persona.id)).toEqual([ + "ceo", + "cto", + "investor", + "client", + "end_user", + ]); + expect(findAudiencePersona("ceo")?.defaultDocuments).toEqual([ + "problem_brief", + "approaches", + "build_plan", + ]); + expect(findAudiencePersona("cto")?.defaultDocuments).toEqual([ + "problem_brief", + "constraints", + "approaches", + "build_plan", + ]); + expect(findAudiencePersona("investor")?.defaultDocuments).toEqual([ + "problem_brief", + "approaches", + "product_requirements", + ]); + expect(findAudiencePersona("client")?.defaultDocuments).toEqual([ + "problem_brief", + "product_requirements", + "build_plan", + ]); + expect(findAudiencePersona("end_user")?.defaultDocuments).toEqual([ + "problem_brief", + "product_requirements", + ]); + + const guidance = formatPersonaGuidance(findAudiencePersona("ceo")!); + expect(guidance).toContain("Audience persona: CEO (ceo)"); + expect(guidance).toContain( + "business outcome, strategic fit, risks, resource ask", + ); + const message = presenterAssignmentMessage({ + projectId: "proj-1", + audience: "ceo", + decisionNeeded: "Approve Q3 staffing and budget", + tone: "Executive and direct", + length: "6 slides", + instruction: + "Prepare an executive deck for our CEO covering the brief, chosen approach and build plan.", + documents: [ + { + artifactId: "art-brief", + kind: "problem_brief", + title: "Problem Brief", + version: 2, + sha256: "a".repeat(64), + content: "# Problem Brief\nCut onboarding time in half.", + }, + ], + }); + expect(message).toContain("Approve Q3 staffing and budget"); + expect(message).toContain("Cut onboarding time in half."); + expect(message).toContain('"personaId": "ceo"'); +}); + +test("deploy_presenter requires approved documents and pins source refs and filed design tokens", async () => { + const sha = (char: string) => char.repeat(64); + const documents: Record[] = [ + { + kind: "problem_brief", + artifactId: "id-brief", + title: "Problem Brief", + version: 2, + sha256: sha("1"), + approvedVersion: 2, + }, + { + kind: "approaches", + artifactId: "id-approaches", + title: "Approaches", + version: 1, + sha256: sha("2"), + }, + { + kind: "build_plan", + artifactId: "id-plan", + title: "Build Plan", + version: 3, + sha256: sha("3"), + approvedVersion: 3, + }, + { + kind: "design", + artifactId: "id-design", + title: "Calm Notebook", + version: 4, + sha256: sha("4"), + approvedVersion: 4, + }, + ]; + const deployed: unknown[] = []; + const sealedAssignments: unknown[] = []; + const mailed: string[] = []; + const fetch = async (path: string, init: RequestInit) => { + const body = init.body ? JSON.parse(String(init.body)) : undefined; + if (path.endsWith("/workflows/deployments")) { + deployed.push(body); + return Response.json({ + id: "run-presenter-1", + tenantId: "tenant", + definitionAssetId: "asset", + status: "deployed", + createdAt: new Date(0).toISOString(), + }); + } + if (path.endsWith("/presenter-assignments") && init.method === "POST") { + sealedAssignments.push(body); + return Response.json(body); + } + if (path.endsWith("/artifacts") && init.method === "GET") + return Response.json({ + artifacts: documents, + inFlight: [], + preferences: {}, + }); + if (path.includes("/artifacts/") && init.method === "GET") { + const found = documents.find((item) => + path.includes("/artifacts/" + String(item["artifactId"])), + )!; + const content = + found["kind"] === "design" + ? JSON.stringify({ + files: { + "DESIGN.md": + '---\nname: "Calm Notebook"\n---\n\n# Calm Notebook', + "designs/index.html": "
Mockup
", + "designs/styles.css": ":root { --primary: #2563eb; }", + }, + }) + : `Full text of ${String(found["title"])}`; + return Response.json({ + artifactId: found["artifactId"], + kind: found["kind"], + title: found["title"], + version: found["version"], + sha256: found["sha256"], + filedAt: new Date(0).toISOString(), + content, + }); + } + return Response.json({ code: "unexpected_route" }, { status: 404 }); + }; + const capabilities = { + resolve: (name: string) => + name === "credentials" + ? { resolve: async () => ({ kind: "http", fetch }) } + : { + send: async (msg: { content: string }) => { + mailed.push(msg.content); + }, + }, + } as unknown as RuntimeCapabilities; + const tools = createDeployTools({ + tenantId: "tenant", + projectId: "project", + domain: "example.invalid", + workflows: { + writer: deployment, + reviewer: deployment, + coder: deployment, + designer: deployment, + presenter: deployment, + }, + maxResultBytes: 65536, + })({ capabilities } as never); + + const callArgs = { + audience: "ceo", + decisionNeeded: "Approve budget and phase 1 kickoff", + kinds: ["problem_brief", "approaches", "build_plan"], + tone: "Executive and direct", + length: "6-8 slides", + instruction: + "Build the CEO approval deck leading with the phase 1 ask and ROI.", + }; + + // Unapproved approaches is refused first. + const unapproved = await tools.run( + { id: "call-1", name: "deploy_presenter", arguments: callArgs }, + new AbortController().signal, + ); + expect(unapproved.isError).toBe(true); + expect(unapproved.content).toStartWith("approaches_not_approved:"); + expect(deployed).toHaveLength(0); + + // Approve approaches, but leave design unapproved: must refuse design_not_approved. + documents.find((doc) => doc["kind"] === "approaches")!["approvedVersion"] = 1; + delete documents.find((doc) => doc["kind"] === "design")!["approvedVersion"]; + const unapprovedDesign = await tools.run( + { id: "call-1b", name: "deploy_presenter", arguments: callArgs }, + new AbortController().signal, + ); + expect(unapprovedDesign.isError).toBe(true); + expect(unapprovedDesign.content).toStartWith("design_not_approved:"); + expect(deployed).toHaveLength(0); + + // Approve design and deploy again. + documents.find((doc) => doc["kind"] === "design")!["approvedVersion"] = 4; + const approved = await tools.run( + { id: "call-2", name: "deploy_presenter", arguments: callArgs }, + new AbortController().signal, + ); + expect(approved.isError).toBeUndefined(); + expect(JSON.parse(String(approved.content))).toEqual({ + runId: "run-presenter-1", + role: "presenter", + status: "deployed", + }); + expect(deployed).toHaveLength(1); + expect(sealedAssignments).toHaveLength(1); + expect(sealedAssignments[0]).toMatchObject({ + runId: "run-presenter-1", + assignment: { + sealed: true, + audience: "ceo", + decisionNeeded: "Approve budget and phase 1 kickoff", + }, + }); + expect(mailed).toHaveLength(1); + expect(mailed[0]).toContain("Approve budget and phase 1 kickoff"); + expect(mailed[0]).toContain("Full text of Problem Brief"); + expect(mailed[0]).toContain("Full text of Approaches"); + expect(mailed[0]).toContain("Full text of Build Plan"); + expect(mailed[0]).toContain("--primary: #2563eb;"); + expect(mailed[0]).toContain('"artifactId": "id-design"'); +}); + +test("file_package refuses until check_deck passes on the current deck, then files approval_package with pinned sources", async () => { + const workspace = await mkdtemp(join(tmpdir(), "sb-package-tools-test-")); + const posted: { path: string; body: unknown }[] = []; + const fetch = async (path: string, init: RequestInit) => { + if (path === "/api/workflow-project-scope") { + return Response.json({ + tenantId: "tenant-1", + projectId: "00000000-0000-4000-8000-000000000001", + domain: "example.invalid", + role: "presenter", + workflows: { + writer: deployment, + reviewer: deployment, + coder: deployment, + designer: deployment, + presenter: deployment, + }, + openNetwork: false, + build: { + assetDirectory: "/tmp", + browserCli: "/tmp/browser", + childPath: "/tmp/child", + dependencyRoots: ["/tmp"], + ripgrepPath: "/tmp/rg", + runtimeVersion: "1.0.0", + }, + }); + } + if (path.endsWith("/artifacts") && init.method === "POST") { + const body = JSON.parse(String(init.body)); + posted.push({ path, body }); + return Response.json({ + artifact: { + artifactId: "pkg-1", + kind: "approval_package", + version: 1, + sha256: "e".repeat(64), + }, + }); + } + return Response.json({ code: "unexpected_route" }, { status: 404 }); + }; + const capabilities = { + resolve: () => ({ resolve: async () => ({ kind: "http", fetch }) }), + } as unknown as RuntimeCapabilities; + const runner = createPackageTools()({ + toolCwd: workspace, + capabilities, + } as never); + const deck = { + theme: { + colors: { + primary: "#2563EB", + secondary: "#0EA5E9", + accent: "#F59E0B", + background: "#FFFFFF", + text: "#111827", + muted: "#6B7280", + }, + fonts: { heading: "Inter", body: "Inter" }, + }, + slides: [ + { + layout: "title", + title: "CEO Sign-Off: Phase 1 Kickoff", + subtitle: "Approve Q3 staffing and scope", + }, + ], + }; + + try { + await writePackageAssignment(workspace, { + audience: "ceo", + personaId: "ceo", + personaLabel: "CEO", + decisionNeeded: "Approve Q3 staffing and scope", + tone: "Executive and direct", + length: "6 slides", + sources: [ + { + artifactId: "id-brief", + kind: "problem_brief", + title: "Problem Brief", + version: 2, + sha256: "1".repeat(64), + }, + ], + }); + + const wrote = await runner.run( + { id: "w1", name: "write_deck", arguments: { deck } }, + new AbortController().signal, + ); + expect(wrote.isError).toBeUndefined(); + expect(JSON.parse(String(wrote.content))).toMatchObject({ + slides: 1, + passed: true, + issues: [], + }); + + // Filing before check_deck must refuse with deck_check_failed. + const beforeCheck = await runner.run( + { id: "f1", name: "file_package", arguments: {} }, + new AbortController().signal, + ); + expect(beforeCheck.isError).toBe(true); + expect(beforeCheck.content).toStartWith("deck_check_failed:"); + expect(posted).toHaveLength(0); + + // Run check_deck. + const checked = await runner.run( + { id: "c1", name: "check_deck", arguments: {} }, + new AbortController().signal, + ); + expect(checked.isError).toBeUndefined(); + expect(checked.content).toStartWith("check passed:"); + + // Mutating deck.json invalidates the check until check_deck runs again. + await writeFile( + join(workspace, DECK_JSON), + JSON.stringify({ ...deck, slides: [...deck.slides] }), + "utf8", + ); + const staleCheck = await runner.run( + { id: "f2", name: "file_package", arguments: {} }, + new AbortController().signal, + ); + expect(staleCheck.isError).toBe(true); + expect(staleCheck.content).toStartWith("deck_check_failed:"); + + // Writing {"passed":true} without sha256/pptxSha256/pdfSha256 is refused. + await writeFile( + join(workspace, DECK_CHECK), + JSON.stringify({ passed: true }), + "utf8", + ); + const hashlessCheck = await runner.run( + { id: "f2b", name: "file_package", arguments: {} }, + new AbortController().signal, + ); + expect(hashlessCheck.isError).toBe(true); + expect(hashlessCheck.content).toStartWith("deck_check_failed:"); + + // Re-check, then mutate deck.pdf on disk: file_package must refuse because pdfSha256 changed. + await runner.run( + { id: "c2a", name: "check_deck", arguments: {} }, + new AbortController().signal, + ); + const originalPdf = await Bun.file(join(workspace, DECK_PDF)).bytes(); + await writeFile(join(workspace, DECK_PDF), "%PDF-1.4\ntampered", "utf8"); + const tamperedPdfRes = await runner.run( + { id: "f2c", name: "file_package", arguments: {} }, + new AbortController().signal, + ); + expect(tamperedPdfRes.isError).toBe(true); + expect(tamperedPdfRes.content).toContain(".builder/deck.pdf changed"); + await writeFile(join(workspace, DECK_PDF), originalPdf); + + // Re-check and verify file_package refuses overriding sealed audience or sources. + await runner.run( + { id: "c2", name: "check_deck", arguments: {} }, + new AbortController().signal, + ); + const mismatchedAudience = await runner.run( + { + id: "f2d", + name: "file_package", + arguments: { audience: "investor" }, + }, + new AbortController().signal, + ); + expect(mismatchedAudience.isError).toBe(true); + expect(mismatchedAudience.content).toStartWith( + "invalid_package_assignment:", + ); + const filed = await runner.run( + { id: "f3", name: "file_package", arguments: {} }, + new AbortController().signal, + ); + expect(filed.isError).toBeUndefined(); + expect(posted).toHaveLength(1); + const body = posted[0]!.body as { + kind: string; + title: string; + content: string; + }; + expect(body.kind).toBe("approval_package"); + expect(body.title).toBe("CEO Sign-Off: Phase 1 Kickoff"); + const bundle = JSON.parse(body.content); + expect(bundle.manifest.audience).toBe("ceo"); + expect(bundle.manifest.decisionNeeded).toBe( + "Approve Q3 staffing and scope", + ); + expect(bundle.manifest.sources).toEqual([ + { + artifactId: "id-brief", + kind: "problem_brief", + title: "Problem Brief", + version: 2, + sha256: "1".repeat(64), + }, + ]); + expect(bundle.deck.slides).toHaveLength(1); + expect(typeof bundle.pptxBase64).toBe("string"); + expect(bundle.pptxBase64.length).toBeGreaterThan(100); + expect(typeof bundle.pdfBase64).toBe("string"); + expect( + Buffer.from(bundle.pdfBase64, "base64").subarray(0, 5).toString("utf8"), + ).toBe("%PDF-"); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("presenter workflow entry binds Medium tier inference and approval_package write grant", async () => { + const { MODEL_PROVIDERS, workflowBindings } = + await import("../config/models.ts"); + const { resolveRoleInference } = + await import("../workflows/role-inference.ts"); + const bindings = workflowBindings(MODEL_PROVIDERS, { + profile: "anthropic_default", + }); + expect(bindings.presenter).toEqual(bindings.writer); + process.env.BUILDER_ROLE_INFERENCE = JSON.stringify( + resolveRoleInference(MODEL_PROVIDERS, "presenter", bindings.presenter), + ); + const entry = await import("../workflows/entries/presenter.ts"); + expect(entry.director).toBeDefined(); + expect(entry.default.steps.present).toMatchObject({ triggers: 1 }); + expect(entry.default.grantRequirements).toEqual([ + { resource: "sb-project:*", action: "read", source: "invoker" }, + { + resource: "sb-artifact:approval_package", + action: "write", + source: "invoker", + }, + ]); +}); diff --git a/apps/hub/src/projects/deploy-tools.ts b/apps/hub/src/projects/deploy-tools.ts index acd66ef8..6e9535f9 100644 --- a/apps/hub/src/projects/deploy-tools.ts +++ b/apps/hub/src/projects/deploy-tools.ts @@ -50,6 +50,13 @@ import { designLookIdeas, } from "../config/design.ts"; import { QUESTIONS_ASKED } from "../config/questions.ts"; +import { + buildPackageAssignment, + formatPersonasCatalog, + presenterAssignmentMessage, + type PresenterDesignTokens, +} from "../config/personas.ts"; +import { DESIGN_STYLES } from "../artifacts/design-tweaks.ts"; import { workflowHttpJson, WorkflowHttpError } from "../workflows/http-json.ts"; import { assignmentToWriterMessage } from "../workflows/writer-task.ts"; import { coderTaskMessage } from "@corbits-builder/coder/contract"; @@ -111,6 +118,18 @@ const DesignApp = type({ lookIdeas: "string > 0", concepts: DesignConcept.array().atLeastLength(1).atMostLength(5), }); +const DeployPresenter = type({ + "+": "reject", + audience: "string > 0", + decisionNeeded: "string > 0", + kinds: type + .enumerated(...APPROVABLE) + .array() + .atLeastLength(1), + tone: "string > 0", + length: "string > 0", + instruction: Instruction, +}); const ArtifactList = type({ "+": "ignore", artifacts: type({ @@ -221,11 +240,13 @@ export function createDeployTools(options?: { reviewer: NativeSpecialistDeployment; coder: NativeSpecialistDeployment; designer: NativeSpecialistDeployment; + presenter?: NativeSpecialistDeployment; }; maxResultBytes: number; }): AnnotatedToolFactory { if (options) for (const deployment of Object.values(options.workflows)) { + if (!deployment) continue; const checked = NativeSpecialistDeployment.assert(deployment); if (!checked.sourceOfferingIds.includes(checked.defaultSourceOfferingId)) throw new Error("invalid_specialist_default_offering"); @@ -278,6 +299,13 @@ export function createDeployTools(options?: { ).join("\n"), inputSchema: { ...DesignApp.toJsonSchema() }, }, + { + name: "deploy_presenter", + description: + "Have a presenter build a slide deck (.pptx and .pdf) and file an approval package for a specific audience from the selected approved documents and the project's filed design tokens. Interview the person first with ask_questions on the audience persona, decision needed, documents to include, and tone and length. Each selected document must be filed and approved at its current version. When a design is filed, its tokens are included and pinned automatically.\n\nStarting audience personas:\n" + + formatPersonasCatalog(), + inputSchema: { ...DeployPresenter.toJsonSchema() }, + }, ]; return defineTool({ id: DEPLOY_TOOLS, @@ -291,6 +319,7 @@ export function createDeployTools(options?: { { name: "deploy_coder" }, { name: "instruct_coder", approval: "ask" }, { name: "design_app", approval: "ask" }, + { name: "deploy_presenter" }, ], factory: (env) => ({ definitions, @@ -425,7 +454,7 @@ export function createDeployTools(options?: { // Every assignment carries the person's preferences that apply to the receiving role. const assign = ( runId: string, - role: "writer" | "reviewer" | "coder" | "designer", + role: "writer" | "reviewer" | "coder" | "designer" | "presenter", preferences: Preferences, correlationId: string, content: string, @@ -442,18 +471,23 @@ export function createDeployTools(options?: { // Mail to a fresh deployment lands in its durable inbox and fires the // run, so the deploy response is enough to send the assignment. const deploy = async ( - role: "writer" | "reviewer" | "coder" | "designer", + role: "writer" | "reviewer" | "coder" | "designer" | "presenter", preferences: Preferences, correlationId: string, content: string, beforeAssign?: (runId: string) => Promise, ) => { + const workflowSpec = options.workflows[role]; + if (!workflowSpec) + throw new Refusal( + role + "_unavailable: no " + role + " workflow is installed", + ); const deployment = WorkflowDeploymentResponse.assert( await hub( "/api/tenants/" + encodeURIComponent(options.tenantId) + "/workflows/deployments", - options.workflows[role], + workflowSpec, DEPLOY_TIMEOUT_MS, ), ); @@ -714,6 +748,82 @@ export function createDeployTools(options?: { error: refusalText(outcome.reason), }, ); + } else if (call.name === "deploy_presenter") { + const input = validArguments(DeployPresenter, call.arguments); + const { byKind, approved, preferences } = await listed(); + const selectedKinds = [...new Set(input.kinds)]; + for (const kind of selectedKinds) { + const ref = required(byKind, kind); + if ( + !agentDecidesApprovals(preferences) && + approved.get(kind) !== ref.version + ) + throw notApproved(kind, ref.version); + } + const documents = await Promise.all( + selectedKinds.map(async (kind) => { + const ref = required(byKind, kind); + const loaded = await readSource(ref); + return { + artifactId: ref.artifactId, + kind, + title: loaded.title, + version: ref.version, + sha256: ref.sha256, + content: loaded.content, + }; + }), + ); + const designRef = byKind.get("design"); + let design: PresenterDesignTokens | undefined; + if (designRef) { + if (approved.get("design") !== designRef.version) + throw notApproved("design", designRef.version); + const rawDesign = ArtifactContent.assert( + await hub( + projectPath + + "/artifacts/" + + encodeURIComponent(designRef.artifactId) + + "?version=" + + String(designRef.version), + ), + ); + const { files } = DesignBundle.assert( + JSON.parse(rawDesign.content), + ); + const stylesCss = files[DESIGN_STYLES]; + design = { + artifactId: designRef.artifactId, + kind: "design", + title: rawDesign.title, + version: designRef.version, + sha256: designRef.sha256, + designMd: files["DESIGN.md"]!, + ...(stylesCss ? { stylesCss } : {}), + }; + } + const assignmentPayload = { + projectId: options.projectId, + audience: input.audience, + decisionNeeded: input.decisionNeeded, + tone: input.tone, + length: input.length, + instruction: input.instruction, + documents, + ...(design ? { design } : {}), + }; + const assignment = buildPackageAssignment(assignmentPayload); + result = await deploy( + "presenter", + preferences, + "presenter-" + input.audience, + presenterAssignmentMessage(assignmentPayload), + (runId) => + hub(projectPath + "/presenter-assignments", { + runId, + assignment, + }), + ); } else return { callId: call.id, diff --git a/apps/hub/src/skills/tools.ts b/apps/hub/src/skills/tools.ts index d9cf3658..d7e78a08 100644 --- a/apps/hub/src/skills/tools.ts +++ b/apps/hub/src/skills/tools.ts @@ -1,9 +1,14 @@ import { type } from "arktype"; import { defineTool } from "@intx/agent/authoring"; import { designSkills } from "@corbits-builder/designer/skills" with { type: "macro" }; +import { presentingSkills } from "@corbits-builder/presenter/skills" with { type: "macro" }; import { documentSkills } from "@corbits-builder/writer/skills" with { type: "macro" }; -const SKILLS = { ...designSkills(), ...documentSkills() }; +const SKILLS = { + ...designSkills(), + ...documentSkills(), + ...presentingSkills(), +}; const ReadSkill = type({ "+": "reject", name: "string > 0", diff --git a/apps/hub/src/workflows/build-packages.ts b/apps/hub/src/workflows/build-packages.ts index 4867584c..ee0e4e0a 100644 --- a/apps/hub/src/workflows/build-packages.ts +++ b/apps/hub/src/workflows/build-packages.ts @@ -14,12 +14,19 @@ function contentVersion(base: string, bundle: string): string { // Each package is an entry under entries/ built on what its role's model binding resolved to. const WORKFLOW_PACKAGES: readonly { name: string; role: WorkflowRole }[] = [ - ...(["coordinator", "writer", "reviewer", "coder", "designer"] as const).map( - (role) => ({ - name: role, - role, - }), - ), + ...( + [ + "coordinator", + "writer", + "reviewer", + "coder", + "designer", + "presenter", + ] as const + ).map((role) => ({ + name: role, + role, + })), { name: "coder-any-package", role: "coder" }, ]; diff --git a/apps/hub/src/workflows/compaction.ts b/apps/hub/src/workflows/compaction.ts index b0cbbb77..b6c3ae3d 100644 --- a/apps/hub/src/workflows/compaction.ts +++ b/apps/hub/src/workflows/compaction.ts @@ -361,7 +361,9 @@ function unchecked(turns: readonly ConversationTurn[]): boolean { // the coordinator waited on a writer that had already answered. const unfiled = (turns: readonly ConversationTurn[]) => !taskCalls(turns).some( - (call) => call.name === "write_artifact" && !call.isError, + (call) => + (call.name === "write_artifact" || call.name === "file_package") && + !call.isError, ); /** diff --git a/apps/hub/src/workflows/entries/presenter.ts b/apps/hub/src/workflows/entries/presenter.ts new file mode 100644 index 00000000..a1b72dd9 --- /dev/null +++ b/apps/hub/src/workflows/entries/presenter.ts @@ -0,0 +1,51 @@ +import { createPresenterAgent } from "@corbits-builder/presenter/agent"; +import { createPresenterWorkflow } from "@corbits-builder/presenter"; +import { mail, type MailToolEnv } from "@intx/tools-mail/sidecar-bundle"; +import { + createPackageTools, + type PackageToolEnv, +} from "../../artifacts/package-tools.ts"; +import { createSkillTools } from "../../skills/tools.ts"; +import { roleInference } from "../role-inference.ts"; +import { + compactingDirector, + dropDegenerateTurn, + trimHistory, +} from "../compaction.ts"; +import { nativePackageName } from "../package-name.ts"; + +const compacting = compactingDirector(nativePackageName("presenter")); + +const { source, contextWindow, parameters } = roleInference("presenter"); +const agent = createPresenterAgent({ + director: compacting.build({ + contextWindow, + compactAt: 0.6, + fileBeforeDone: true, + }), + id: "presenter", + capabilities: [], + inference: { ...source, parameters }, + tools: [createPackageTools(), mail, createSkillTools(["presenting"])], +}); +const workflow = createPresenterWorkflow({ + id: "corbits-builder-presenter", + address: "presenter", + agent, + inference: { literal: parameters }, + grantRequirements: [ + { resource: "sb-project:*", action: "read", source: "invoker" }, + { + resource: "sb-artifact:approval_package", + action: "write", + source: "invoker", + }, + ], +}); +// The directors module of this package: the director the agent names and the compactors it uses. +export const director = compacting.factory; +export const historyCompactor = trimHistory( + contextWindow - parameters.maxTokens, +); +export const degenerateTurnCompactor = dropDegenerateTurn; +export default workflow; diff --git a/apps/hub/src/workflows/install-for-tenant.ts b/apps/hub/src/workflows/install-for-tenant.ts index ea5f6423..21b9702a 100644 --- a/apps/hub/src/workflows/install-for-tenant.ts +++ b/apps/hub/src/workflows/install-for-tenant.ts @@ -320,6 +320,7 @@ const ROLES = [ "reviewer", "coder", "designer", + "presenter", ] as const; /** A stored descriptor is current only while it pins this build's packages and, for each role, an offering serving its source. */ @@ -335,15 +336,18 @@ async function isCurrent( built.map((item) => item.packageName + "@" + item.version), ); if ( - ![...ROLES, "coderAnyPackage" as const].every((key) => - pins.has(installed[key].pin), - ) + ![...ROLES, "coderAnyPackage" as const].every((key) => { + const entry = installed[key]; + return entry !== undefined && pins.has(entry.pin); + }) ) return false; const visible = await listVisibleOfferings(db, tenantId); for (const role of ROLES) { + const entry = installed[role]; + if (!entry) return false; const row = visible.find( - (entry) => entry.offering.id === installed[role].defaultSourceOfferingId, + (candidate) => candidate.offering.id === entry.defaultSourceOfferingId, ); if (!row || !(await servesSource(db, directory, row, bindings[role]))) return false; @@ -508,6 +512,7 @@ export async function installForTenant( "coder", ), designer: deployment("@corbits-builder/designer-native", "designer"), + presenter: deployment("@corbits-builder/presenter-native", "presenter"), }); await ports.db.transaction(async (tx) => { const [current] = await tx diff --git a/apps/hub/src/workflows/packaged.test.ts b/apps/hub/src/workflows/packaged.test.ts index 3935f513..24743438 100644 --- a/apps/hub/src/workflows/packaged.test.ts +++ b/apps/hub/src/workflows/packaged.test.ts @@ -41,12 +41,14 @@ test("production entries expose concrete native workflows without callback crede const coderAnyPackage = (await import("./entries/coder-any-package.ts")) .default; const designer = (await import("./entries/designer.ts")).default; + const presenter = (await import("./entries/presenter.ts")).default; expect(coordinator.steps.coordinate).toMatchObject({ triggers: "unbounded" }); expect(writer.steps.write).toMatchObject({ triggers: 1 }); expect(reviewer.steps.review).toMatchObject({ triggers: 1 }); expect(coder.steps.build).toMatchObject({ triggers: "unbounded" }); expect(coderAnyPackage.steps.build).toMatchObject({ triggers: "unbounded" }); expect(designer.steps.design).toMatchObject({ triggers: "unbounded" }); + expect(presenter.steps.present).toMatchObject({ triggers: 1 }); // Secure by default: only the coder a person opted into declares install_packages without an ask. const installTool = (workflow: typeof coder) => { const step = workflow.steps.build; @@ -68,6 +70,7 @@ test("production entries expose concrete native workflows without callback crede coder, coderAnyPackage, designer, + presenter, ]) expect(workflow.credentialBindings ?? []).toEqual([]); expect( @@ -78,6 +81,7 @@ test("production entries expose concrete native workflows without callback crede coder, coderAnyPackage, designer, + presenter, ]), ).not.toMatch(/prepareOwnerContext|finish_task/); }); @@ -93,7 +97,7 @@ test("built npm tarballs contain importable production workflow metadata and def ); try { const manifest = await buildWorkflowPackages(directory, "1.0.0", inference); - expect(manifest).toHaveLength(6); + expect(manifest).toHaveLength(7); for (const metadata of manifest) { const extracted = join(directory, metadata.assetName); await mkdir(extracted); diff --git a/apps/hub/src/workflows/spawn-role.ts b/apps/hub/src/workflows/spawn-role.ts index 74706257..37fbd0a4 100644 --- a/apps/hub/src/workflows/spawn-role.ts +++ b/apps/hub/src/workflows/spawn-role.ts @@ -11,6 +11,7 @@ const ROLE_TAGS: readonly (readonly [tag: string, role: WorkflowRole])[] = [ ["reviewer.version", "reviewer"], ["coder.version", "coder"], ["designer.version", "designer"], + ["presenter.version", "presenter"], ["coordinator.version", "coordinator"], ]; diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 24303512..963a24b8 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -325,6 +325,7 @@ export const wording = { reviewer: "reviewer", designer: "designer", coder: "app builder", + presenter: "presenter", }, change: "What needs to change…", fix: "What needs fixing…", @@ -357,6 +358,7 @@ export const wording = { reviewer: "Reviewer", designer: "Designer", coder: "App builder", + presenter: "Presenter", }, /** Before the run's first tool call: the hub has seen nothing from it yet. */ starting: "getting started", @@ -377,6 +379,9 @@ export const wording = { "package.json": "the app setup", "index.html": "the main screen", "styles.css": "the styles", + "deck.json": "the slide deck", + "deck.pptx": "the PowerPoint deck", + "deck.pdf": "the PDF deck", } as Record, }, /** Verbs for file work, one picked per file so a line reads like a person at work. */ @@ -428,6 +433,7 @@ export const wording = { document ? `Started the writer · ${document}` : "Started the writer", design_app: () => "Started the designer", deploy_coder: () => "Started the app builder", + deploy_presenter: () => "Started the presenter", instruct_coder: () => "Sent the app builder a change", review_artifact: (document?: string) => document ? `Asked for a review · ${document}` : "Asked for a review", @@ -469,6 +475,10 @@ export const wording = { review_artifact: () => "asking for a review", design_app: () => "starting the designer", deploy_coder: () => "starting the app builder", + deploy_presenter: () => "starting the presenter", + write_deck: () => "building the slide deck", + check_deck: () => "checking the slide deck", + file_package: () => "filing the approval package", instruct_coder: () => "waiting for you to approve a change for the app builder", } as Record string>, @@ -607,6 +617,7 @@ export const wording = { deploy_coder: "the app", instruct_coder: "the app", design_app: "the design", + deploy_presenter: "the approval package", } as Record, /** What a hand-off works on when nothing names it yet. */ unnamed: "the task", @@ -626,6 +637,7 @@ export const wording = { reviewer: (subject: string) => `Reviewed ${subject}`, designer: (subject: string) => `Drafted ${subject}`, coder: (subject: string) => `Built ${subject}`, + presenter: (subject: string) => `Prepared ${subject}`, }, /** The run ended without filing what it was asked for. */ ended: (subject: string) => `Worked on ${subject}`, @@ -744,6 +756,7 @@ export const wording = { writtenBy: { problem_brief: "Coordinator", design: "Designer", + approval_package: "Presenter", } as Record, writer: "Writer", /** The document written after this one once it is approved, and the line saying so. */ diff --git a/apps/web/src/pages/workspace/composer-placeholder.test.ts b/apps/web/src/pages/workspace/composer-placeholder.test.ts index 5ce4e8ea..8282e778 100644 --- a/apps/web/src/pages/workspace/composer-placeholder.test.ts +++ b/apps/web/src/pages/workspace/composer-placeholder.test.ts @@ -74,6 +74,17 @@ describe("composer placeholder", () => { ).toBe("Ask the app builder…"); }); + test("the presenter is named while it is working", () => { + expect( + placeholder({ + status: standing({ + working: [{ role: "presenter", state: "working" }], + app: { state: "running" }, + }), + }), + ).toBe("Ask the presenter…"); + }); + test("a running app beats a failure", () => { expect( placeholder({ diff --git a/apps/web/src/pages/workspace/dispatch-card.tsx b/apps/web/src/pages/workspace/dispatch-card.tsx index d6d08a30..2a44c0f4 100644 --- a/apps/web/src/pages/workspace/dispatch-card.tsx +++ b/apps/web/src/pages/workspace/dispatch-card.tsx @@ -23,6 +23,7 @@ const ROLE_OF: Record = { deploy_coder: "coder", instruct_coder: "coder", design_app: "designer", + deploy_presenter: "presenter", }; export const DISPATCH_TOOLS: ReadonlySet = new Set( @@ -173,6 +174,7 @@ export function targetKind(dispatch: Dispatch): string | undefined { if (dispatch.tool === "design_app") return dispatch.concept === undefined ? "design" : undefined; if (dispatch.role === "coder") return "app"; + if (dispatch.role === "presenter") return "approval_package"; return undefined; } @@ -363,6 +365,7 @@ const RUN_TOOL: Record = { reviewer: "review_artifact", coder: "deploy_coder", designer: "design_app", + presenter: "deploy_presenter", }; /** A run at work the conversation has no hand-off for, such as one started before a reload. */ diff --git a/apps/web/src/pages/workspace/earlier-document.ts b/apps/web/src/pages/workspace/earlier-document.ts index 9f838853..3f24a918 100644 --- a/apps/web/src/pages/workspace/earlier-document.ts +++ b/apps/web/src/pages/workspace/earlier-document.ts @@ -22,6 +22,14 @@ const BUILDS_FROM: Readonly> = { "build_plan", "design", ], + presenter: [ + "problem_brief", + "constraints", + "approaches", + "product_requirements", + "build_plan", + "design", + ], }; function names(text: string, phrase: string): boolean { diff --git a/bun.lock b/bun.lock index f1decfbd..7ca3d49c 100644 --- a/bun.lock +++ b/bun.lock @@ -44,6 +44,7 @@ "@corbits-builder/coordinator": "workspace:*", "@corbits-builder/designer": "workspace:*", "@corbits-builder/hub": "workspace:*", + "@corbits-builder/presenter": "workspace:*", "@corbits-builder/schemas": "workspace:*", "@corbits-builder/writer": "workspace:*", "@emulators/core": "0.12.3", @@ -65,6 +66,7 @@ "@corbits-builder/coder": "workspace:*", "@corbits-builder/coordinator": "workspace:*", "@corbits-builder/designer": "workspace:*", + "@corbits-builder/presenter": "workspace:*", "@corbits-builder/reviewer": "workspace:*", "@corbits-builder/schemas": "workspace:*", "@corbits-builder/writer": "workspace:*", @@ -640,6 +642,14 @@ "@intx/workflow": "workspace:*", }, }, + "workflows/presenter": { + "name": "@corbits-builder/presenter", + "version": "0.1.0-rc.3", + "dependencies": { + "@intx/agent": "workspace:*", + "@intx/workflow": "workspace:*", + }, + }, "workflows/reviewer": { "name": "@corbits-builder/reviewer", "version": "0.6.0", @@ -747,6 +757,8 @@ "@corbits-builder/hub": ["@corbits-builder/hub@workspace:apps/hub"], + "@corbits-builder/presenter": ["@corbits-builder/presenter@workspace:workflows/presenter"], + "@corbits-builder/reviewer": ["@corbits-builder/reviewer@workspace:workflows/reviewer"], "@corbits-builder/schemas": ["@corbits-builder/schemas@workspace:packages/schemas"], diff --git a/package.json b/package.json index 9418d2e1..5acbdb75 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "@corbits-builder/coordinator": "workspace:*", "@corbits-builder/designer": "workspace:*", "@corbits-builder/hub": "workspace:*", + "@corbits-builder/presenter": "workspace:*", "@corbits-builder/schemas": "workspace:*", "@corbits-builder/writer": "workspace:*", "@emulators/core": "0.12.3", diff --git a/packages/schemas/src/installed-workflows.ts b/packages/schemas/src/installed-workflows.ts index 4c367ec2..92118d8e 100644 --- a/packages/schemas/src/installed-workflows.ts +++ b/packages/schemas/src/installed-workflows.ts @@ -15,6 +15,7 @@ export const InstalledWorkflows = type({ // The coder whose install_packages does not ask, for a project that turned asking off. coderAnyPackage: NativeSpecialistDeployment, designer: NativeSpecialistDeployment, + presenter: NativeSpecialistDeployment, }); export type InstalledWorkflows = typeof InstalledWorkflows.infer; @@ -32,6 +33,7 @@ export const WorkflowProjectScope = type({ reviewer: NativeSpecialistDeployment, coder: NativeSpecialistDeployment, designer: NativeSpecialistDeployment, + "presenter?": NativeSpecialistDeployment, }, // Whether the project's build sandboxes reach any public host rather than only the npm registry. openNetwork: "boolean", diff --git a/packages/schemas/src/models.ts b/packages/schemas/src/models.ts index f2e03446..a69cafb4 100644 --- a/packages/schemas/src/models.ts +++ b/packages/schemas/src/models.ts @@ -117,7 +117,7 @@ export type ModelProvider = typeof ModelProvider.infer; export const Effort = type("'low' | 'medium' | 'high' | 'max'"); export type Effort = typeof Effort.infer; export const ModelRole = type( - "'coordinator' | 'writer' | 'designer' | 'builder' | 'reviewer'", + "'coordinator' | 'writer' | 'designer' | 'builder' | 'reviewer' | 'presenter'", ); export type ModelRole = typeof ModelRole.infer; export const Tier = type("'low' | 'medium' | 'high' | 'max'"); diff --git a/packages/schemas/src/roles.ts b/packages/schemas/src/roles.ts index cf1caaa1..6d37629c 100644 --- a/packages/schemas/src/roles.ts +++ b/packages/schemas/src/roles.ts @@ -1,6 +1,8 @@ import { type } from "arktype"; -export const SpawnedRole = type("'writer' | 'reviewer' | 'coder' | 'designer'"); +export const SpawnedRole = type( + "'writer' | 'reviewer' | 'coder' | 'designer' | 'presenter'", +); export type SpawnedRole = typeof SpawnedRole.infer; export const WorkflowRole = SpawnedRole.or("'coordinator'"); export type WorkflowRole = typeof WorkflowRole.infer; diff --git a/workflows/coordinator/src/prompt.ts b/workflows/coordinator/src/prompt.ts index c894f462..73d4a4b2 100644 --- a/workflows/coordinator/src/prompt.ts +++ b/workflows/coordinator/src/prompt.ts @@ -52,6 +52,9 @@ Each concept is filed as a design option the person can click through, and they ## Building the app When the brief, constraints, approaches, requirements and build plan all exist, tell the person in your own words that it looks ready to build and that they can ask anything first, and use deploy_coder once for the whole app. The plan's phases order the coder's own work within that one assignment. Send later changes to the running coder with instruct_coder. +## Sharing and presenting for an audience +When the person asks to share or present the project for an audience (such as "Share this with our CEO"), interview them first with ask_questions before starting the presenter: confirm the audience persona (CEO, CTO, investor, client, end user, or their own), the decision needed from that audience, which approved documents to include (preselecting that persona's default documents), and the tone and slide length. Once they answer and the selected documents are approved, use deploy_presenter with their choices. + ## Talking to the person Talk like a person on their team, a sharp product partner. Writer mail is addressed to you, and your replies reach the person: tell them what changed for their app. The workspace already shows each specialist's live progress, so speak when something changes for them: a document is ready, a decision is theirs, the build reaches a milestone or hits a problem. A progress note that adds nothing new needs no reply. A specialist hears from you only to pass on something new the person said. - Say only what you know from the mail of the writers, designer and coder, and from the document list with its inFlight runs. diff --git a/workflows/presenter/README.md b/workflows/presenter/README.md new file mode 100644 index 00000000..8243b9ef --- /dev/null +++ b/workflows/presenter/README.md @@ -0,0 +1,38 @@ +# Presenter + +The package presenter: builds a slide deck and approval package for one audience +persona from the project's approved documents and filed design tokens. + +## Where it fits + +This package only declares data. It imports `@intx/agent/authoring` and +`@intx/workflow/definition` from Interchange. The +[hub's presenter entry][entry] imports it, supplies the model, director and +tools, and builds it into the workflow the [coordinator][coordinator] starts +with `deploy_presenter`. The presenter's tools are the hub's `write_deck`, +`check_deck`, `file_package`, `mail` and `read_skill`; it has no project read +tool, because its assignment carries the selected documents and design tokens +in full. + +## Layout + +- `src/prompt.ts`: the system prompt. +- `src/agent.ts`: `createPresenterAgent`, which requires an explicit provider + and model. +- `src/workflow.ts`: `createPresenterWorkflow`, a bounded single-step workflow + that runs once on its first message. +- `skills/presenting/`: the `presenting` skill (`SKILL.md`), covering the core + deck rules and per-persona guidance (`ceo`, `cto`, `investor`, `client`, + `end_user`). +- `src/skills.ts`: a Bun macro that inlines `skills/` when an agent bundle + is built. + +## Working on it + +What a presentation should contain lives in its skill rather than the prompt; +prompt conventions are in [AGENTS.md][agents]. `bun run typecheck` and `bun run test` cover it, and `bun run dev` runs it in a +real project. + +[entry]: https://github.com/corbitsdev/corbits-builder/blob/main/apps/hub/src/workflows/entries/presenter.ts +[coordinator]: https://github.com/corbitsdev/corbits-builder/tree/main/workflows/coordinator +[agents]: https://github.com/corbitsdev/corbits-builder/blob/main/AGENTS.md diff --git a/workflows/presenter/package.json b/workflows/presenter/package.json new file mode 100644 index 00000000..94f38c84 --- /dev/null +++ b/workflows/presenter/package.json @@ -0,0 +1,18 @@ +{ + "name": "@corbits-builder/presenter", + "version": "0.1.0-rc.3", + "private": true, + "license": "SEE LICENSE IN ../../LICENSE.md", + "author": "Sawyer Cutler ", + "type": "module", + "exports": { + ".": "./src/workflow.ts", + "./agent": "./src/agent.ts", + "./prompt": "./src/prompt.ts", + "./skills": "./src/skills.ts" + }, + "dependencies": { + "@intx/agent": "workspace:*", + "@intx/workflow": "workspace:*" + } +} diff --git a/workflows/presenter/skills/presenting/SKILL.md b/workflows/presenter/skills/presenting/SKILL.md new file mode 100644 index 00000000..d571efe3 --- /dev/null +++ b/workflows/presenter/skills/presenting/SKILL.md @@ -0,0 +1,24 @@ +--- +name: presenting +description: Use when building a slide deck and approval package for a specific audience persona - lead with the decision needed, put one idea per slide, reuse the project's design tokens for the deck theme, and end with the explicit ask. +--- + +# Presenting + +An approval package gives one audience what they need to understand the project and make a clear decision. It pairs the selected source documents with a focused slide deck in `.pptx` and `.pdf`. + +## Core rules + +1. **Lead with the decision needed**: Open by stating the decision or sign-off this audience is being asked to make and why it matters now. +2. **One idea per slide**: Give each slide a headline that states its takeaway as a complete thought, backed by concrete facts, numbers and names from the selected documents. +3. **Reuse the project's design tokens for the deck theme**: Pull `primary`, `secondary`, `accent`, `background`, `text`, `muted` and font families (`heading`, `body`) from the project's filed `DESIGN.md` and `styles.css` when a design exists so the deck matches the product's look. +4. **End with the explicit ask**: Close the deck with a slide that states the exact decision needed, the scope being approved, and what happens next once approved. +5. **Record the pinned package assignment**: Pass the pinned `assignment` object from your assignment message as the `assignment` argument to `write_deck` (or `file_package`) so `.builder/package-assignment.json` is recorded before filing. + +## Per-persona guidance + +- **CEO (`ceo`)**: Focus on business outcome, strategic fit, key risks and the resource ask. Keep the deck to 6-8 slides, executive and direct, grounded in the problem brief, approaches and build plan. +- **CTO (`cto`)**: Focus on architecture, constraints, security boundaries, technical trade-offs and the phased build plan. Keep the deck to 8-12 slides, technical and rigorous, grounded in the problem brief, constraints, approaches and build plan. +- **Investor (`investor`)**: Focus on problem size, differentiation, user value and traction roadmap. Keep the deck to 8-10 slides, crisp and evidence-led, grounded in the problem brief, approaches and product requirements. +- **Client (`client`)**: Focus on capabilities, workflow fit, delivery timeline and sign-off scope. Keep the deck to 8-10 slides, clear and collaborative, grounded in the problem brief, product requirements and build plan. +- **End user (`end_user`)**: Focus on day-to-day experience, screens and key workflows. Keep the deck to 5-7 slides, practical and approachable in plain language, grounded in the problem brief and product requirements. diff --git a/workflows/presenter/src/agent.ts b/workflows/presenter/src/agent.ts new file mode 100644 index 00000000..1a80cad3 --- /dev/null +++ b/workflows/presenter/src/agent.ts @@ -0,0 +1,29 @@ +import { + defineAgent, + type AnnotatedToolFactory, + type BaseEnv, + type DirectorRef, + type InferencePreference, +} from "@intx/agent/authoring"; +import { presenterPrompt } from "./prompt.ts"; +import { version } from "../package.json"; + +export function createPresenterAgent(options: { + readonly id: string; + readonly inference: InferencePreference; + readonly tools: readonly AnnotatedToolFactory[]; + readonly director?: DirectorRef; + readonly capabilities: readonly string[]; +}) { + if (!options.inference.provider.trim() || !options.inference.model.trim()) + throw new Error("Presenter requires an explicit provider and model"); + return defineAgent({ + id: options.id, + systemPrompt: presenterPrompt, + tools: options.tools, + ...(options.director ? { director: options.director } : {}), + capabilities: options.capabilities, + inference: { sources: [options.inference] }, + tags: { "presenter.version": version }, + }); +} diff --git a/workflows/presenter/src/presenter.test.ts b/workflows/presenter/src/presenter.test.ts new file mode 100644 index 00000000..d485afd6 --- /dev/null +++ b/workflows/presenter/src/presenter.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "bun:test"; +import { createPresenterAgent } from "./agent.ts"; +import { presenterPrompt } from "./prompt.ts"; +import { presentingSkills } from "./skills.ts"; +import { createPresenterWorkflow } from "./workflow.ts"; + +test("createPresenterAgent requires explicit provider and model and sets presenter tags", () => { + expect(() => + createPresenterAgent({ + id: "presenter", + inference: { provider: "", model: "claude-sonnet-5-5" }, + tools: [], + capabilities: [], + }), + ).toThrow("Presenter requires an explicit provider and model"); + + const agent = createPresenterAgent({ + id: "presenter", + inference: { provider: "anthropic", model: "claude-sonnet-5-5" }, + tools: [], + capabilities: [], + }); + expect(agent.id).toBe("presenter"); + expect(agent.systemPrompt).toBe(presenterPrompt); + expect(agent.tags).toEqual({ "presenter.version": "0.1.0-rc.3" }); + expect(presenterPrompt).toContain("write_deck"); + expect(presenterPrompt).toContain("check_deck"); + expect(presenterPrompt).toContain("file_package"); + expect(presenterPrompt).toContain("never ask the person directly"); +}); + +test("createPresenterWorkflow creates a bounded single-trigger mail workflow", () => { + const agent = createPresenterAgent({ + id: "presenter", + inference: { provider: "anthropic", model: "claude-sonnet-5-5" }, + tools: [], + capabilities: [], + }); + const workflow = createPresenterWorkflow({ + id: "corbits-builder-presenter", + address: "presenter", + agent, + inference: { literal: { maxTokens: 32768 } }, + }); + expect(workflow.id).toBe("corbits-builder-presenter"); + expect(workflow.triggers).toEqual([{ type: "mail", to: "presenter" }]); + expect(workflow.steps.present).toMatchObject({ + triggers: 1, + drainBehavior: "wait", + }); +}); + +test("presentingSkills bundles the presenting skill and per-persona guidance", () => { + const skills = presentingSkills(); + const skill = skills["presenting"]?.["SKILL.md"]; + expect(skill).toBeDefined(); + expect(skill).toContain("Lead with the decision needed"); + expect(skill).toContain("One idea per slide"); + expect(skill).toContain("Reuse the project's design tokens"); + expect(skill).toContain("End with the explicit ask"); + for (const persona of ["ceo", "cto", "investor", "client", "end_user"]) { + expect(skill).toContain(persona); + } +}); diff --git a/workflows/presenter/src/prompt.ts b/workflows/presenter/src/prompt.ts new file mode 100644 index 00000000..a1913570 --- /dev/null +++ b/workflows/presenter/src/prompt.ts @@ -0,0 +1,7 @@ +export const presenterPrompt = `You are a presenter: a product partner who builds a slide deck and approval package for one audience from the project's approved documents and design tokens. + +Read your assignment in full: the audience persona, the decision needed, the tone and length, the selected documents in full, and the project's filed design tokens. Read the presenting skill with read_skill before building the deck. + +Lead with the decision needed, keep one idea per slide, reuse the project's design tokens for the deck theme when a design is filed, and end with the explicit ask. Build the deck with write_deck (passing the pinned assignment object from your assignment message as the assignment argument so .builder/package-assignment.json is recorded), check it with check_deck until it passes cleanly, file the approval package with file_package, and mail a short summary back in plain words. + +If you are blocked on a question that the documents and assignment leave open, mail the coordinator and never ask the person directly.`; diff --git a/workflows/presenter/src/skills.ts b/workflows/presenter/src/skills.ts new file mode 100644 index 00000000..79144fa3 --- /dev/null +++ b/workflows/presenter/src/skills.ts @@ -0,0 +1,34 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const files = (root: string, directory = root): [string, string][] => + readdirSync(directory, { withFileTypes: true }).flatMap((entry) => + entry.isDirectory() + ? files(root, join(directory, entry.name)) + : [ + [ + relative(root, join(directory, entry.name)), + readFileSync(join(directory, entry.name), "utf8"), + ], + ], + ); + +// A Bun macro: it runs when an agent bundle is built, so the presenting skill ships inside the +// workflow package. Each skill is its directory's files keyed by path, SKILL.md first. +export const presentingSkills = (): Record> => { + const root = join(import.meta.dir, "..", "skills"); + return Object.fromEntries( + readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => [ + entry.name, + Object.fromEntries( + files(join(root, entry.name)).sort(([a], [b]) => { + if (a === "SKILL.md") return -1; + if (b === "SKILL.md") return 1; + return a.localeCompare(b); + }), + ), + ]), + ); +}; diff --git a/workflows/presenter/src/workflow.ts b/workflows/presenter/src/workflow.ts new file mode 100644 index 00000000..0b44b2ca --- /dev/null +++ b/workflows/presenter/src/workflow.ts @@ -0,0 +1,44 @@ +import type { AgentDefinition, BaseEnv } from "@intx/agent/authoring"; +import { defineWorkflow, step, type Selector } from "@intx/workflow/definition"; + +const STEP_TIMEOUT_MS = 600_000; + +// One bounded presentation run: builds the deck, checks it, and files the approval package. +export function createPresenterWorkflow(options: { + readonly id: string; + readonly address: string; + readonly agent: AgentDefinition; + readonly inference: Selector; + readonly stepTimeoutMs?: number; + readonly credentialBindings?: Parameters< + typeof defineWorkflow + >[0]["credentialBindings"]; + readonly grantRequirements?: Parameters< + typeof defineWorkflow + >[0]["grantRequirements"]; +}) { + return defineWorkflow({ + id: options.id, + ...(options.credentialBindings + ? { credentialBindings: options.credentialBindings } + : {}), + ...(options.grantRequirements + ? { grantRequirements: options.grantRequirements } + : {}), + mailAccept: { invoker: true }, + triggers: [{ type: "mail", to: options.address }], + sidecarPlacement: { + capabilities: [{ capability: "runtime:posix", effect: "require" }], + }, + steps: { + present: step({ + agent: options.agent, + input: { from: "trigger.payload" }, + inference: options.inference, + drainBehavior: "wait", + timeout: options.stepTimeoutMs ?? STEP_TIMEOUT_MS, + triggers: 1, + }), + }, + }); +} diff --git a/workflows/presenter/tsconfig.json b/workflows/presenter/tsconfig.json new file mode 100644 index 00000000..596e2cf7 --- /dev/null +++ b/workflows/presenter/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +}