From 888f8956cc347a544912c342c7f3d8d77d1c08f6 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sun, 4 Oct 2026 01:31:01 +0800 Subject: [PATCH 1/3] feat(sdk,mcp): register meeting block contract and validators (MEET-4) --- _brief.md | 32 +++++++++++++ docs/audits/block-api-coverage.md | 1 + docs/meeting-block.md | 44 ++++++++++++++++++ packages/mcp/README.md | 1 + packages/mcp/scripts/blockTypes.test.mts | 16 +++++++ packages/mcp/src/server.ts | 11 +++-- packages/sdk/src/blockCatalogue.test.ts | 46 +++++++++++++++++++ packages/sdk/src/blockCatalogue.ts | 39 +++++++++++++--- packages/sdk/src/blockPropSchemas.ts | 24 ++++++++++ packages/sdk/src/index.ts | 1 + packages/server/src/ableOidc.test.ts | 4 +- packages/server/src/ai/agent.ts | 3 ++ .../server/src/ai/agentBlockTypes.test.ts | 25 ++++++++++ .../blockeditor/MeetingBlockPlaceholder.tsx | 14 ++++++ packages/ui/src/blockeditor/kit/index.ts | 3 +- packages/ui/src/blockeditor/model.ts | 2 +- .../blockeditor/registryCatalogue.test.tsx | 11 ++++- 17 files changed, 261 insertions(+), 16 deletions(-) create mode 100644 _brief.md create mode 100644 docs/meeting-block.md create mode 100644 packages/ui/src/blockeditor/MeetingBlockPlaceholder.tsx diff --git a/_brief.md b/_brief.md new file mode 100644 index 00000000..81d6e5cf --- /dev/null +++ b/_brief.md @@ -0,0 +1,32 @@ +# MEET-4 — Block catalogue + prop schemas + MCP surface for `meeting` + +You are a Worker agent on the OpenBook team. Work ONLY in this worktree (`/Users/eliot/Workspaces/OpenBook-wt-meet-4`, branch `feat/meet-4-meeting-catalogue`). Do NOT push. Do NOT touch main. Conventional commits (`feat(sdk,mcp): … (MEET-4)`). + +## Task +Register a new `meeting` block type in the single-source catalogue so sdk/mcp/server stay drift-free. The UI view lands later (MEET-5) — your job is the type system + MCP/agent surface + docs. Anchors (recon-time hints — trust the code): +- `CATALOGUE_LITERAL` `packages/sdk/src/blockCatalogue.ts:62-113` — entries `{type, category, nature, parent?, props, kitValue?, hint}`. Study how `form` (kit) is declared; mirror its decisions (commit 63969ae3 / FORM-3 is the template epic). +- Prop schemas: `packages/sdk/src/blockPropSchemas.ts` `fields` :51-75 + `CATALOGUE_LITERAL` entry; exports `BLOCK_PROP_SCHEMAS`/`BLOCK_PROP_JSON_SCHEMAS`. +- MCP: `packages/mcp/src/server.ts` picks types up from the catalogue; update `mcp/README.md` prop table (enforced by `mcp/scripts/readme.test.mts` + `blockTypes.test.mts`); regenerate `docs/audits/block-api-coverage.md` via `mcp/scripts/coverage.test.mts` (EXEMPT entries need a written reason — avoid unless truly necessary). +- Server agent validators: `server/src/ai/agentBlockTypes.test.ts`. +- IMPORTANT: ui's `registryCatalogue.test.tsx` requires catalogue kit entries ↔ registered custom blocks to match BOTH directions. The ui view doesn't exist yet. If adding `meeting` as a kit entry breaks that guard, register a minimal placeholder kit registration the way the catalogue/tests structurally require OR structure the entry so the guard passes — pick the smallest honest solution and DOCUMENT it in your report (MEET-5 replaces it). Breaking `pnpm verify` is not acceptable. + +## Representation decision (you make it, then document it — MEET-5/6/7 build against it) +Derive prop shapes from what the component will need, favoring existing catalogue idioms (existing tests/conventions win over this brief): +- `status`: 'idle' | 'recording' | 'processing' | 'done' +- audio chunks: ordered list of `{assetId, durationMs, startedAtMs?}` (or parallel arrays if object-arrays aren't idiomatic — check how other blocks store structured props) +- transcript segments: `{startMs, endMs, text}` list — decide props vs child blocks after reading how `form` stores structured kitValue; prefer props unless size/CRDT concerns say otherwise; justify in one paragraph. +- `summary` (string/rich), `startedAt`, `title?` +- manual notes: child blocks (the block should be a container or have a notes container slot — follow the catalogue's `nature`/`parent` idioms, see how form/kit blocks nest). +Write the final representation contract as a short `docs/`-level or in-code doc comment AND in `_report.md` — MEET-5 consumes it verbatim. + +## Acceptance +- `list_block_types` returns `meeting` with propsSchema; `add_blocks` / `update_block_props` validate it; invalid props rejected (test). +- All drift guards green: mcp readme/blockTypes/coverage tests, server agentBlockTypes test, ui registryCatalogue guard. +- Coverage matrix regenerated and committed. + +## Definition of done +- `pnpm verify` green FOREGROUND here, output in report. (Provisioned; artifact-failure symptom → rebuild viewer/server/mcp bundles.) +- All committed, not pushed. `_report.md`: outcome first, head sha, criterion→test map, THE REPRESENTATION CONTRACT, deviations, open questions. Terse. + +## Rules +Never poll external state. Stuck after a real attempt → commit + report. Never delete/weaken existing tests (reviewers check the commit RANGE). diff --git a/docs/audits/block-api-coverage.md b/docs/audits/block-api-coverage.md index 615fa00b..7a19438c 100644 --- a/docs/audits/block-api-coverage.md +++ b/docs/audits/block-api-coverage.md @@ -46,6 +46,7 @@ | tooltipcard | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ | | dbview | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ | | dbform | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ | +| meeting | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ | | form | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ | | openbook.ledger/journal-entry | ✅ | ✅ | ➖ | ✅ | ➖ | ✅ | | openbook.ledger/trial-balance | ✅ | ✅ | ➖ | ✅ | ➖ | ✅ | diff --git a/docs/meeting-block.md b/docs/meeting-block.md new file mode 100644 index 00000000..4e52a824 --- /dev/null +++ b/docs/meeting-block.md @@ -0,0 +1,44 @@ +# Meeting block — THE REPRESENTATION CONTRACT (MEET-4) + +`meeting` is a `kit` block with `nature: 'container'`, no required parent, and +`kitValue: false`. It publishes no reactive value. Its `children` are ordinary +manual-note blocks (paragraphs, todos, headings, groups, etc.), not transcript +segments. No dedicated notes slot or child-only type is required. + +All top-level props are optional. Missing status means `idle`; missing arrays +mean empty lists; missing summary/title mean empty text; missing startedAt means +not started. These are consumer defaults, not schema-inserted persisted values. +As with other block props, patches shallow-merge; `null` removes a top-level key. +Unknown top-level props remain allowed for forward compatibility. + +| Prop | Stored shape and meaning | +| --- | --- | +| `status` | `'idle' \| 'recording' \| 'processing' \| 'done'`. Validation checks the enum, not state transitions. | +| `audioChunks` | Ordered array of `{assetId: string, durationMs: number, startedAtMs?: number}`. `assetId` is a nonempty asset reference (max 512 characters), never inline audio. `durationMs` is the chunk duration; optional `startedAtMs` is an offset from meeting start, **not** Unix time. Array order is capture/playback order. | +| `transcript` | Ordered array of `{startMs: number, endMs: number, text: string}`. Offsets are relative to meeting start; `endMs >= startMs`. Text is plain text. Array order is display order; overlapping segments are allowed. | +| `summary` | Plain string; no rich-text runs or Markdown interpretation is required. | +| `startedAt` | Unix epoch milliseconds, a finite nonnegative number. | +| `title` | Optional plain string. | + +All durations and offsets are finite nonnegative numbers in milliseconds; +fractional milliseconds are accepted. Every listed nested field is required +except `startedAtMs`; nested objects reject extra keys and null fields. Empty +arrays and empty transcript text are valid. Cross-field `endMs >= startMs` is +checked at runtime and described in the JSON schema (standard JSON Schema cannot +express a comparison to a sibling field). Asset existence, timeline sorting, +status transitions, and transcription size limits are producer responsibilities. + +Audio chunks and transcript segments use structured props, following existing +kit option/rich-run arrays and the form's structured schema prop. They are +machine-produced snapshots, while manual notes need independently editable CRDT +children. Each array is one prop value: updates replace the whole array, with +no per-segment merge guarantee. Recording/transcription producers should batch +updates and serialize writes to avoid concurrent array replacement losing data. +This keeps the first representation small and consistent; large-transcript +storage migration, if needed later, must explicitly version this contract. + +MEET-4 registers a minimal meeting shell through `registerArtifactKit` in both +editor and viewer hosts. It displays title, status, and saved note-block count; +notes remain stored but are not yet rendered/editable inside the shell. It has +no slash-menu entry or recording controls. MEET-5 replaces this renderer and +must render the existing child blocks rather than migrate them into props. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index ce54205f..9d0002ff 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -93,6 +93,7 @@ Call `list_block_types` for the machine-readable source of truth: every entry ha | `tooltipcard` | `term:string`, `tip:string` | | `dbview` | `pageId:string` | | `dbform` | `databaseId:string`, `viewId:string` | +| `meeting` | `status:"idle"/"recording"/"processing"/"done"`, `audioChunks:[{assetId:string,durationMs:number,startedAtMs?:number}]`, `transcript:[{startMs:number,endMs:number,text:string}]`, `summary:string`, `startedAt:number`, `title:string`; container children are manual notes; [representation contract](../../docs/meeting-block.md) | | `form` | `formId:string`, `submissionKey:string`, `enabled:boolean`, `databaseId:string`, `schema:object`, `label:string`, `description:string` | Shared input/frame props are `name`, `label`, and `description` strings plus `compact` and `interactive` booleans. A structured option's `value` defaults to a slug of its `label`; `selected` contains those string values. diff --git a/packages/mcp/scripts/blockTypes.test.mts b/packages/mcp/scripts/blockTypes.test.mts index 85f78bba..230b5b1f 100644 --- a/packages/mcp/scripts/blockTypes.test.mts +++ b/packages/mcp/scripts/blockTypes.test.mts @@ -157,6 +157,22 @@ async function main(): Promise { check('an invalid structured option is refused with a typed error naming opts', isError(badOpts) && /"opts"/.test(resultText(badOpts)) && /object/i.test(resultText(badOpts))); + const meetingProps = {status: 'done', audioChunks: [{assetId: 'audio-1', durationMs: 500, startedAtMs: 0}], transcript: [{startMs: 0, endMs: 500, text: 'Hello'}], summary: 'Agreed', startedAt: 1234, title: 'Review'}; + const meetingPage = await seed.savePage({name: 'Meeting target', data: {editor: 'blocks', blockdoc: {blocks: [{id: 'meeting1', type: 'meeting', children: [{id: 'note1', type: 'paragraph', text: [{t: 'Notes'}]}]}]}, editorjs: {blocks: []}, values: [], names: []}}); + const meetingUpdate = await mcp.client.callTool({name: 'update_block_props', arguments: {pageId: meetingPage.id, blockId: 'meeting1', props: meetingProps}}); + check('meeting structured props update is accepted', !isError(meetingUpdate)); + const meetingCreate = await mcp.client.callTool({name: 'append_blocks', arguments: {pageId: meetingPage.id, blocks: [{type: 'meeting', props: meetingProps, children: [{type: 'paragraph', text: 'Manual notes'}]}]}}); + check('meeting creation with manual note children is accepted', !isError(meetingCreate)); + for (const props of [{status: 'paused'}, {audioChunks: [{assetId: 'a', durationMs: -1}]}, {transcript: [{startMs: 2, endMs: 1, text: 'Reversed'}]}]) { + const badCreate = await mcp.client.callTool({name: 'append_blocks', arguments: {pageId: meetingPage.id, blocks: [{type: 'meeting', props}]}}); + const badUpdate = await mcp.client.callTool({name: 'update_block_props', arguments: {pageId: meetingPage.id, blockId: 'meeting1', props}}); + check(`meeting rejects invalid creation and update: ${JSON.stringify(props)}`, isError(badCreate) && isError(badUpdate)); + } + const meetingListing = JSON.parse(resultText(await mcp.client.callTool({name: 'list_block_types', arguments: {types: ['meeting']}}))); + assert.equal(meetingListing.blocks.length, 1); + assert.deepEqual(meetingListing.blocks[0].propsSchema.properties.transcript.items.required, ['startMs', 'endMs', 'text']); + check('meeting listing publishes container nature and structured propsSchema', meetingListing.blocks[0].nature === 'container'); + console.log('\nAPI-2: plugin block types — rejected while uninstalled, accepted once installed'); const uninstalled = await mcp.client.callTool({ name: 'create_artifact_page', diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index f5fdb6a1..1942e7cd 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -25,6 +25,7 @@ import { findUnknownBlockType, FORM_FIELD_KINDS, invalidBlockProps, + invalidBlockTreeProps, insertBlocks, isHttpUrl, KIT_VALUE_BLOCK_TYPES, @@ -609,7 +610,7 @@ function nestedBlockSchema(typeDesc: string, propsDesc: string): z.ZodType - blockTreeError(blocks, {maxDepth: MAX_BLOCK_DEPTH, maxNodes: MAX_BLOCK_NODES}); + blockTreeError(blocks, {maxDepth: MAX_BLOCK_DEPTH, maxNodes: MAX_BLOCK_NODES}) ?? invalidBlockTreeProps(blocks); /** * The write-tool kind an MCP mutation maps to (the same identifiers the in-app diff --git a/packages/sdk/src/blockCatalogue.test.ts b/packages/sdk/src/blockCatalogue.test.ts index 0ef41ead..4310c0e1 100644 --- a/packages/sdk/src/blockCatalogue.test.ts +++ b/packages/sdk/src/blockCatalogue.test.ts @@ -17,6 +17,7 @@ import { CONTAINER_BLOCK_TYPES, findUnknownBlockType, invalidBlockProps, + invalidBlockTreeProps, isPluginBlockType, KNOWN_BLOCK_TYPE_IDS, MAX_BLOCK_DEPTH, @@ -242,3 +243,48 @@ describe('generated tool text', () => { for (const type of KNOWN_BLOCK_TYPE_IDS) expect(guidance).toContain(type); }); }); + + +describe('meeting representation contract (MEET-4)', () => { + it('is a kit container with typed structured props and no reactive value', () => { + expect(blockTypeInfo('meeting')).toMatchObject({category: 'kit', nature: 'container', kitValue: false}); + expect(blockTreeError([{type: 'meeting', children: [{type: 'paragraph', text: 'Manual notes'}]}])).toBeNull(); + for (const status of ['idle', 'recording', 'processing', 'done']) { + expect(invalidBlockProps('meeting', {status, startedAt: 1234, title: 'Review', summary: 'Agreed.', + audioChunks: [{assetId: 'audio-1', durationMs: 500, startedAtMs: 0}, {assetId: 'audio-2', durationMs: 250}], + transcript: [{startMs: 0, endMs: 500, text: 'Hello'}], + })).toBeNull(); + } + expect(invalidBlockProps('meeting', {})).toBeNull(); + expect(invalidBlockProps('meeting', {status: null, audioChunks: null, transcript: null, summary: null, startedAt: null, title: null})).toBeNull(); + }); + + it.each([ + {status: 'paused'}, {startedAt: -1}, {startedAt: '2026-01-01'}, {summary: []}, {title: 1}, + {audioChunks: ['asset']}, {audioChunks: [{assetId: 'a'}]}, {audioChunks: [{assetId: '', durationMs: 1}]}, + {audioChunks: [{assetId: 'a', durationMs: -1}]}, {audioChunks: [{assetId: 'a', durationMs: 1, startedAtMs: -1}]}, + {audioChunks: [{assetId: 'a', durationMs: Infinity}]}, {audioChunks: [{assetId: 'a', durationMs: 1, extra: true}]}, + {transcript: [{startMs: 0, text: 'Missing end'}]}, {transcript: [{startMs: 2, endMs: 1, text: 'Reversed'}]}, + {transcript: [{startMs: -1, endMs: 1, text: 'Negative'}]}, {transcript: [{startMs: 0, endMs: 1, text: 1}]}, + ])('rejects malformed props: %j', (props) => { + expect(invalidBlockProps('meeting', props)).toContain('Invalid prop'); + }); + + it('publishes nested item schemas and required fields to agents', () => { + const {blocks} = JSON.parse(blockCatalogueText([], ['meeting'])); + expect(blocks).toHaveLength(1); + expect(blocks[0].propsSchema.properties.status.enum).toEqual(['idle', 'recording', 'processing', 'done']); + expect(blocks[0].propsSchema.properties.audioChunks.items.required).toEqual(['assetId', 'durationMs']); + expect(blocks[0].propsSchema.properties.transcript.items.required).toEqual(['startMs', 'endMs', 'text']); + }); +}); + + +describe('creation prop validation', () => { + it('validates nested blocks with the same schemas as prop updates', () => { + expect(invalidBlockTreeProps([{type: 'group', children: [{type: 'meeting', props: {status: 'paused'}}]}])).toContain('Invalid prop "status"'); + expect(invalidBlockTreeProps([{type: 'heading', props: {level: 'two'}}])).toContain('Invalid prop "level"'); + expect(invalidBlockTreeProps([{type: 'meeting', props: []}])).toContain('expected an object'); + expect(invalidBlockTreeProps([{type: 'meeting'}, {type: 'plugin/widget', props: {anything: true}}, {type: 'meeting', props: {title: null, future: {x: 1}}}])).toBeNull(); + }); +}); diff --git a/packages/sdk/src/blockCatalogue.ts b/packages/sdk/src/blockCatalogue.ts index ae2dc957..2c86bf60 100644 --- a/packages/sdk/src/blockCatalogue.ts +++ b/packages/sdk/src/blockCatalogue.ts @@ -31,7 +31,7 @@ export {BLOCK_PROP_JSON_SCHEMAS, BLOCK_PROP_SCHEMAS} from './blockPropSchemas'; /** How a block stores content: `container` blocks carry child blocks in * `children`, `text` blocks carry rich text in `text`, `void` blocks carry - * only `props` (all kit widgets are void). */ + * only `props`. Kit blocks may also be containers. */ export type BlockNature = 'container' | 'text' | 'void'; /** The value shapes per-type prop validation understands. Deliberately coarse @@ -109,6 +109,7 @@ const CATALOGUE_LITERAL = [ {type: 'tooltipcard', category: 'kit', nature: 'void', props: {term: 'string', tip: 'string'}, hint: '{term,tip}'}, {type: 'dbview', category: 'kit', nature: 'void', props: {pageId: 'string'}, hint: 'embedded live database view {pageId} — the page hosting the database'}, {type: 'dbform', category: 'kit', nature: 'void', props: {databaseId: 'string', viewId: 'string'}, hint: 'embedded database form {databaseId,viewId} — a live reference, never a copied schema or capability'}, + {type: 'meeting', category: 'kit', nature: 'container', kitValue: false, props: {status: 'string', audioChunks: 'array', transcript: 'array', summary: 'string', startedAt: 'number', title: 'string'}, hint: 'meeting recording {status?,audioChunks?:[{assetId,durationMs,startedAtMs?}],transcript?:[{startMs,endMs,text}],summary?,startedAt?,title?}; times in ms, startedAt is Unix epoch; children hold manual notes; see docs/meeting-block.md'}, {type: 'form', category: 'kit', nature: 'void', kitValue: false, props: {formId: 'string', submissionKey: 'string', enabled: 'boolean', databaseId: 'string', schema: 'object', label: 'string', description: 'string'}, hint: 'public form definition {formId,submissionKey,enabled,databaseId?,schema}'}, ] as const satisfies readonly BlockTypeInfo[]; @@ -134,9 +135,9 @@ export const KIT_VALUE_BLOCK_TYPES: ReadonlySet = new Set( BLOCK_TYPE_CATALOGUE.filter((entry) => entry.kitValue === true).map((entry) => entry.type), ); -/** Core types whose `children` hold ordinary blocks. */ -export const CONTAINER_BLOCK_TYPES: ReadonlySet = new Set( - BLOCK_TYPE_CATALOGUE.filter((e) => e.nature === 'container').map((e) => e.type as CoreBlockType), +/** Catalogued types whose `children` hold ordinary blocks (including kit containers). */ +export const CONTAINER_BLOCK_TYPES: ReadonlySet = new Set( + BLOCK_TYPE_CATALOGUE.filter((e) => e.nature === 'container').map((e) => e.type), ); /** Core types that carry editable rich text. */ @@ -284,7 +285,7 @@ export function blockTreeError( structural = parentType ? `A "${type}" block must be a direct child of a "${needs}" block, not a "${parentType}" block (at "${here}").` : `A "${type}" block can't be top-level — it belongs directly inside a "${needs}" block (at "${here}").`; - } else if (hasChildren && !CONTAINER_BLOCK_TYPES.has(type as CoreBlockType)) { + } else if (hasChildren && !CONTAINER_BLOCK_TYPES.has(type)) { structural = `A "${type}" block can't hold children — only container blocks (${[...CONTAINER_BLOCK_TYPES].join(', ')}) do (at "${here}"). The nested blocks would be dropped.`; } else if (type === 'table' && hasChildren) { structural = raggedTableError(children, here); @@ -339,6 +340,30 @@ export function invalidBlockProps(type: string, props: Record): return `Invalid prop "${prop}" of a "${type}" block: ${issue.message} (got ${clipJson(props[prop])}).`; } +/** Validate declared props throughout a creation payload. Call blockTreeError + * first to enforce structure/size limits. Plugin and unknown props retain the + * same permissive behavior as update_block_props. */ +export function invalidBlockTreeProps(blocks: readonly unknown[], depth = 1): string | null { + if (depth > TYPE_WALK_MAX_DEPTH) return null; // structure validation owns depth errors + for (const raw of blocks) { + if (!raw || typeof raw !== 'object') continue; + const block = raw as {type?: unknown; props?: unknown; children?: unknown}; + const type = String(block.type ?? ''); + if (block.props !== undefined) { + if (!block.props || typeof block.props !== 'object' || Array.isArray(block.props)) { + return `Invalid props of a "${type}" block: expected an object.`; + } + const error = invalidBlockProps(type, block.props as Record); + if (error) return error; + } + if (Array.isArray(block.children)) { + const error = invalidBlockTreeProps(block.children, depth + 1); + if (error) return error; + } + } + return null; +} + const clipJson = (v: unknown): string => { const s = JSON.stringify(v) ?? String(v); return s.length > 40 ? `${s.slice(0, 40)}…` : s; @@ -388,10 +413,10 @@ export function addBlocksGuidance(): string { 'Append rich blocks to a page — text, layouts, tables, media, interactive inputs, and charts. User approves before they are added.', 'Each block is {type, text?, props?, children?}. `text` is a plain string (or rich runs [{"t","a":{b,i,u,s,c,a}}]); `children` nests blocks inside containers. Call list_block_types for the full catalogue including installed plugin blocks.', `TEXT: ${core.filter((e) => e.nature === 'text' && !e.parent).map(hinted).join('; ')}.`, - `CONTAINERS (use children): ${core.filter((e) => e.nature === 'container' || e.parent).map(hinted).join('; ')}. Give every table row the same number of cells.`, + `CONTAINERS (use children): ${BLOCK_TYPE_CATALOGUE.filter((e) => e.nature === 'container' || e.parent).map(hinted).join('; ')}. Give every table row the same number of cells.`, `MEDIA/OTHER: ${core.filter((e) => e.nature === 'void').map(hinted).join('; ')}.`, `INPUTS (each publishes props.name into the reactive scope): ${kit.filter((e) => e.kitValue).map(hinted).join('; ')}.`, - `REACTIVE DISPLAY/ACTIONS (props.source is a JS expression over input names): ${kit.filter((e) => !e.kitValue).map(hinted).join('; ')}.`, + `REACTIVE DISPLAY/ACTIONS (props.source is a JS expression over input names): ${kit.filter((e) => !e.kitValue && e.nature !== 'container').map(hinted).join('; ')}.`, 'Example: a budget widget → [{"type":"heading","text":"Budget","props":{"level":2}},{"type":"columns","children":[{"type":"column","props":{"span":5},"children":[{"type":"slider","props":{"name":"spent","label":"Spent","value":80,"min":0,"max":200}},{"type":"number","props":{"name":"budget","label":"Budget","value":120}}]},{"type":"column","props":{"span":7},"children":[{"type":"kitchart","props":{"kind":"bar","title":"Spent vs budget","labels":"Spent, Budget","source":"[spent, budget]"}},{"type":"statuslight","props":{"label":"On track","source":"budget - spent","okAt":0,"warnAt":-20}}]}]}].', ].join('\n'); } diff --git a/packages/sdk/src/blockPropSchemas.ts b/packages/sdk/src/blockPropSchemas.ts index 0daf2dcf..56e07684 100644 --- a/packages/sdk/src/blockPropSchemas.ts +++ b/packages/sdk/src/blockPropSchemas.ts @@ -43,6 +43,22 @@ const opts = array(option, 'Structured selectable options.'); const attrs = object({b: boolean(), i: boolean(), u: boolean(), s: boolean(), c: boolean(), a: string('Safe http, https, or mailto link.')}); const run = object({t: string('Run text.'), a: attrs}, 'One rich-text run.', ['t']); const runs = array(run, 'Rich-text runs.'); +// MEET-4 representation contract: docs/meeting-block.md. Nested objects are +// strict; top-level props retain the catalogue's nullable patch semantics. +const audioChunk = object({ + assetId: {schema: z.string().min(1).max(512), json: {type: 'string', minLength: 1, maxLength: 512}}, + durationMs: number('Chunk duration in milliseconds.', 0), + startedAtMs: number('Offset from the meeting start in milliseconds.', 0), +}, 'One audio asset in capture order.', ['assetId', 'durationMs']); +const transcriptSegment = object({ + startMs: number('Inclusive offset from the meeting start in milliseconds.', 0), + endMs: number('Exclusive offset from the meeting start in milliseconds; must be >= startMs.', 0), + text: string('Plain transcript text.'), +}, 'One transcript segment in display order.', ['startMs', 'endMs', 'text']); +transcriptSegment.schema = transcriptSegment.schema.refine( + (segment) => segment.endMs >= segment.startMs, + {message: 'endMs must be greater than or equal to startMs', path: ['endMs']}, +); const common = {bg: text}; const frame = {name: text, label: text, description: text, compact: boolean(), interactive: boolean()}; const inputText = {...frame, value: text, placeholder: text}; @@ -71,6 +87,14 @@ const fields = { formula: {...frame, source: expression('Expression evaluated over the reactive scope.')}, linkcard: {title: text, url: text, description: text}, tooltipcard: {term: text, tip: text}, dbview: {pageId: id}, dbform: {databaseId: id, viewId: id}, + meeting: { + status: enumeration(['idle', 'recording', 'processing', 'done'], 'Absent means idle.'), + audioChunks: array(audioChunk, 'Audio chunks in capture order; replace the entire array when updating.'), + transcript: array(transcriptSegment, 'Plain-text segments in display order; replace the entire array when updating.'), + summary: string('Plain-text summary.'), + startedAt: number('Meeting start as Unix epoch milliseconds.', 0), + title: text, + }, form: {formId: id, submissionKey: text, enabled: boolean(), databaseId: id, schema: freeObject, label: text, description: text}, } satisfies Record>; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 6ca60969..325bb884 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -554,6 +554,7 @@ export { unknownBlockTypeMessage, blockTreeError, invalidBlockProps, + invalidBlockTreeProps, blockCatalogueText, addBlocksGuidance, type BlockNature, diff --git a/packages/server/src/ableOidc.test.ts b/packages/server/src/ableOidc.test.ts index 13ee3512..cca5c906 100644 --- a/packages/server/src/ableOidc.test.ts +++ b/packages/server/src/ableOidc.test.ts @@ -568,7 +568,9 @@ describe('able OIDC relying party', () => { issuer: 'local', scope: 'read', createdBy: 'test', - expiresAt: new Date(NOW + 60_000), + // PAT expiry uses the database wall clock, not the injected OIDC clock. + // Keep this valid-PAT fixture valid after the fixed OIDC date has passed. + expiresAt: new Date(Date.now() + 60_000), }); const app = createApp(store, undefined, new PageHub(), { ableOidc: {...idp.options, discoveryTtlMs: 0}, diff --git a/packages/server/src/ai/agent.ts b/packages/server/src/ai/agent.ts index d63033e7..a3f57675 100644 --- a/packages/server/src/ai/agent.ts +++ b/packages/server/src/ai/agent.ts @@ -19,6 +19,7 @@ import { CONTAINER_BLOCK_TYPES, findUnknownBlockType, invalidBlockProps, + invalidBlockTreeProps, KIT_VALUE_BLOCK_TYPES, providerSettings, movePageTool, @@ -1060,6 +1061,8 @@ export class AgentRunner { if (structural) return structural; const bad = unknownBlockTypeMessage(findUnknownBlockType(blocks, {installedPluginIds: await this.installedPluginIds()})); if (bad) return bad; + const propError = invalidBlockTreeProps(blocks); + if (propError) return propError; const normalizeBlockText = (value: unknown): unknown => { if (!value || typeof value !== 'object' || Array.isArray(value)) return value; const block = value as Record; diff --git a/packages/server/src/ai/agentBlockTypes.test.ts b/packages/server/src/ai/agentBlockTypes.test.ts index eef33a35..c337fa50 100644 --- a/packages/server/src/ai/agentBlockTypes.test.ts +++ b/packages/server/src/ai/agentBlockTypes.test.ts @@ -261,3 +261,28 @@ describe('list_block_types', () => { expect(afterCatalogue.pluginBlocks.every((entry: {category: string}) => entry.category === 'plugin')).toBe(true); }); }); + + +describe('meeting agent surface (MEET-4)', () => { + it('accepts notes and structured props, rejects malformed creation and updates', async () => { + const page = await store.upsertPage({name: `meeting-${seq}`, data: { + editor: 'blocks', blockdoc: {blocks: [{id: 'meeting1', type: 'meeting'}]}, editorjs: {blocks: []}, values: [], names: [], + }}); + const props = {status: 'done', audioChunks: [{assetId: 'audio-1', durationMs: 500}], transcript: [{startMs: 0, endMs: 500, text: 'Hello'}]}; + const added = await runTool('add_blocks', {pageId: page.id, blocks: [{type: 'meeting', props, children: [{type: 'paragraph', text: 'Notes'}]}]}); + expect(added.result).toContain('SUGGESTED for review'); + expect((await runTool('update_block_props', {pageId: page.id, blockId: 'meeting1', props})).result).toContain('SUGGESTED for review'); + for (const invalid of [{status: 'paused'}, {audioChunks: [{assetId: 'a', durationMs: -1}]}, {transcript: [{startMs: 2, endMs: 1, text: 'Reversed'}]}]) { + for (const [tool, args] of [ + ['add_blocks', {pageId: page.id, blocks: [{type: 'meeting', props: invalid}]}], + ['update_block_props', {pageId: page.id, blockId: 'meeting1', props: invalid}], + ] as const) { + const response = await runTool(tool, args); + expect(response.result).toContain('Invalid prop'); + expect(response.events.some((event) => event.type === 'suggestions')).toBe(false); + } + } + const listed = JSON.parse((await runTool('list_block_types', {types: ['meeting']})).result); + expect(listed.blocks.find((block: {type: string}) => block.type === 'meeting').propsSchema.properties.transcript.items.required).toEqual(['startMs', 'endMs', 'text']); + }); +}); diff --git a/packages/ui/src/blockeditor/MeetingBlockPlaceholder.tsx b/packages/ui/src/blockeditor/MeetingBlockPlaceholder.tsx new file mode 100644 index 00000000..c2ee56a1 --- /dev/null +++ b/packages/ui/src/blockeditor/MeetingBlockPlaceholder.tsx @@ -0,0 +1,14 @@ +import {blockChildren, blockProp} from './model'; +import type {CustomBlockDef, CustomBlockProps} from './registry'; + +/** MEET-5 replaces this shell. The container model already preserves notes; + * this placeholder deliberately offers no recording controls or slash entry. */ +const MeetingBlockPlaceholder = ({block}: CustomBlockProps) => ( +
+ {blockProp(block, 'title') || 'Meeting'} +

{blockProp(block, 'status') || 'idle'} — Meeting view coming soon.

+

{blockChildren(block)?.length ?? 0} note blocks saved.

+
+); + +export const MEETING_BLOCK: CustomBlockDef = {type: 'meeting', render: MeetingBlockPlaceholder}; diff --git a/packages/ui/src/blockeditor/kit/index.ts b/packages/ui/src/blockeditor/kit/index.ts index f18f6aa5..1c9dbda5 100644 --- a/packages/ui/src/blockeditor/kit/index.ts +++ b/packages/ui/src/blockeditor/kit/index.ts @@ -4,6 +4,7 @@ import {INPUT2_BLOCKS} from './inputs2'; import {PROGRESS_BLOCKS} from './progress'; import {CHART_BLOCKS} from './charts'; import {CARD_BLOCKS} from './cards'; +import {MEETING_BLOCK} from '../MeetingBlockPlaceholder'; export {evalExpr, formatValue, inputScope, INPUT_TYPES} from './scope'; export {CHART_KINDS} from './charts'; @@ -17,7 +18,7 @@ export {CHART_KINDS} from './charts'; * out of reusable, collaborative blocks. */ export function registerArtifactKit(): void { - for (const def of [...INPUT_BLOCKS, ...INPUT2_BLOCKS, ...PROGRESS_BLOCKS, ...CHART_BLOCKS, ...CARD_BLOCKS]) { + for (const def of [...INPUT_BLOCKS, ...INPUT2_BLOCKS, ...PROGRESS_BLOCKS, ...CHART_BLOCKS, ...CARD_BLOCKS, MEETING_BLOCK]) { const d = def as unknown as CustomBlockDef; // Tag the built-ins so the slash menu files them under "Interactive blocks" // (third-party plugin blocks fall through to "Extensions"). diff --git a/packages/ui/src/blockeditor/model.ts b/packages/ui/src/blockeditor/model.ts index 77191ff9..51e44df8 100644 --- a/packages/ui/src/blockeditor/model.ts +++ b/packages/ui/src/blockeditor/model.ts @@ -94,7 +94,7 @@ export const TEXT_BLOCKS: ReadonlySet = TEXT_BLOCK_TYPES; /** Block types whose `children` hold ordinary blocks — the catalogue's * `container` nature. */ -export const CONTAINER_BLOCKS: ReadonlySet = CONTAINER_BLOCK_TYPES; +export const CONTAINER_BLOCKS: ReadonlySet = CONTAINER_BLOCK_TYPES; export type BlockMap = Y.Map; diff --git a/packages/ui/src/blockeditor/registryCatalogue.test.tsx b/packages/ui/src/blockeditor/registryCatalogue.test.tsx index 24adc0b3..e0d85802 100644 --- a/packages/ui/src/blockeditor/registryCatalogue.test.tsx +++ b/packages/ui/src/blockeditor/registryCatalogue.test.tsx @@ -27,7 +27,7 @@ import {registeredBlockTypes} from './registry'; import {registerArtifactKit} from './kit'; import {registerReactiveBlocks} from './reactiveBlocks'; import {INPUT_TYPES, inputValue} from './kit/scope'; -import {CONTAINER_BLOCKS, createDoc, rootBlocks, TEXT_BLOCKS} from './model'; +import {blockToJSON, CONTAINER_BLOCKS, createDoc, rootBlocks, TEXT_BLOCKS} from './model'; import {registerDatabaseBlock} from '@/components/database/InlineDatabaseBlock'; import {registerDatabaseFormBlock} from '@/components/database/DatabaseFormBlock'; import {registerFormBlock} from './FormBlockView'; @@ -62,6 +62,15 @@ describe('registry ↔ catalogue drift guard', () => { expect(KNOWN_BLOCK_TYPE_IDS.size).toBe(BLOCK_TYPE_CATALOGUE.length); }); + it('meeting container notes and structured props survive the UI model round trip', () => { + const props = {status: 'done', audioChunks: [{assetId: 'audio-1', durationMs: 500}], transcript: [{startMs: 0, endMs: 500, text: 'Hello'}]}; + const doc = createDoc([{type: 'meeting', props, children: [{type: 'paragraph', text: 'Manual notes'}]}]); + const json = blockToJSON(rootBlocks(doc).get(0)); + expect(json.props).toEqual(props); + expect(json.children).toHaveLength(1); + expect(json.children?.[0]).toMatchObject({type: 'paragraph', text: [{t: 'Manual notes'}]}); + }); + it('the model\'s nature sets ARE the catalogue\'s', () => { expect(TEXT_BLOCKS).toBe(TEXT_BLOCK_TYPES); expect(CONTAINER_BLOCKS).toBe(CONTAINER_BLOCK_TYPES); From 1f97ba060841c77e65943d0d7c998fb4d09c574d Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sun, 4 Oct 2026 01:33:05 +0800 Subject: [PATCH 2/3] docs: record MEET-4 verification and representation contract --- _report.md | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 _report.md diff --git a/_report.md b/_report.md new file mode 100644 index 00000000..12e57ec2 --- /dev/null +++ b/_report.md @@ -0,0 +1,87 @@ +# MEET-4 — Drew + +MEET-4 complete. `pnpm verify` passed in the foreground (exit 0); implementation and report committed separately. Not pushed. + +Implementation HEAD: `888f8956cc347a544912c342c7f3d8d77d1c08f6`. The following report-only commit records this verified implementation SHA; final HEAD is the report commit. + +## Criterion → test map + +| Criterion | Evidence | +| --- | --- | +| Catalogue, schemas, defaults/patch semantics, invalid nested values | `packages/sdk/src/blockCatalogue.test.ts` | +| `list_block_types` exposes meeting + propsSchema; MCP creation/update acceptance and rejection | `packages/mcp/scripts/blockTypes.test.mts` (67 checks) | +| Agent `add_blocks` and `update_block_props` validate before suggesting | `packages/server/src/ai/agentBlockTypes.test.ts` (13 tests) | +| Runtime kit registry matches catalogue; notes/props survive UI serialization | `packages/ui/src/blockeditor/registryCatalogue.test.tsx` | +| README and generated matrix drift guards | `packages/mcp/scripts/readme.test.mts`, `coverage.test.mts`; 45 catalogue types + 9 plugin blocks, no exemptions | +| Workspace definition of done | `pnpm verify`, foreground, exit 0 (outside sandbox for filesystem watchers) | + +## Validation output + +Full output: [verification log](/Users/eliot/.bb/thread-storage/meet-4-verify-888f8956.log). Command: `pnpm verify`, foreground, outside sandbox; exit **0**. + +```text +SDK: 32 files, 565 tests passed +UI: 244 files, 2320 tests passed +App: 2 files, 7 tests passed +Server: 97 files, 1327 passed / 6 existing skipped (1333) +MCP: All 45 catalogue types + 9 plugin blocks have MCP API coverage; exemptions: none. +Server e2e: ALL 256 CHECKS PASSED +MCP e2e: ALL 70 CHECKS PASSED +``` + +ESLint-rule tests, builds, generated-file guard, workspace typechecks and lint also passed. Pre-commit ESLint/typechecks and commitlint passed. + +## THE REPRESENTATION CONTRACT + +`meeting` is a `kit` block with `nature: 'container'`, no required parent, and +`kitValue: false`. It publishes no reactive value. Its `children` are ordinary +manual-note blocks (paragraphs, todos, headings, groups, etc.), not transcript +segments. No dedicated notes slot or child-only type is required. + +All top-level props are optional. Missing status means `idle`; missing arrays +mean empty lists; missing summary/title mean empty text; missing startedAt means +not started. These are consumer defaults, not schema-inserted persisted values. +As with other block props, patches shallow-merge; `null` removes a top-level key. +Unknown top-level props remain allowed for forward compatibility. + +| Prop | Stored shape and meaning | +| --- | --- | +| `status` | `'idle' \| 'recording' \| 'processing' \| 'done'`. Validation checks the enum, not state transitions. | +| `audioChunks` | Ordered array of `{assetId: string, durationMs: number, startedAtMs?: number}`. `assetId` is a nonempty asset reference (max 512 characters), never inline audio. `durationMs` is the chunk duration; optional `startedAtMs` is an offset from meeting start, **not** Unix time. Array order is capture/playback order. | +| `transcript` | Ordered array of `{startMs: number, endMs: number, text: string}`. Offsets are relative to meeting start; `endMs >= startMs`. Text is plain text. Array order is display order; overlapping segments are allowed. | +| `summary` | Plain string; no rich-text runs or Markdown interpretation is required. | +| `startedAt` | Unix epoch milliseconds, a finite nonnegative number. | +| `title` | Optional plain string. | + +All durations and offsets are finite nonnegative numbers in milliseconds; +fractional milliseconds are accepted. Every listed nested field is required +except `startedAtMs`; nested objects reject extra keys and null fields. Empty +arrays and empty transcript text are valid. Cross-field `endMs >= startMs` is +checked at runtime and described in the JSON schema (standard JSON Schema cannot +express a comparison to a sibling field). Asset existence, timeline sorting, +status transitions, and transcription size limits are producer responsibilities. + +Audio chunks and transcript segments use structured props, following existing +kit option/rich-run arrays and the form's structured schema prop. They are +machine-produced snapshots, while manual notes need independently editable CRDT +children. Each array is one prop value: updates replace the whole array, with +no per-segment merge guarantee. Recording/transcription producers should batch +updates and serialize writes to avoid concurrent array replacement losing data. +This keeps the first representation small and consistent; large-transcript +storage migration, if needed later, must explicitly version this contract. + +MEET-4 registers a minimal meeting shell through `registerArtifactKit` in both +editor and viewer hosts. It displays title, status, and saved note-block count; +notes remain stored but are not yet rendered/editable inside the shell. It has +no slash-menu entry or recording controls. MEET-5 replaces this renderer and +must render the existing child blocks rather than migrate them into props. + +## Deviations and open questions + +- The first full verification attempt hit transient `EADDRINUSE` on MCP port 4410. The listener had exited on inspection; no process was killed and no test was weakened. +- The new negative test exposed that creation paths previously validated structure but skipped props. Added shared SDK `invalidBlockTreeProps` to MCP creation/append/insert and agent `add_blocks`, validating all declared schemas recursively before writes or suggestions. Existing update validation remains shared. +- Full verification exposed an unrelated dated fixture in `ableOidc.test.ts`: its purportedly valid PAT expired at fixed September 8, 2026 time, while SQL validates against `now()`. Reproduced the 401-versus-302 failure; changed only the fixture to `Date.now() + 60_000`, preserving every assertion. The focused regression passed afterward. +- A later full run ended without a normal summary after export fixture failures and zero-test collection failures while the host disk was nearly full. The export suite (`bookFolder.test.ts`) passed all 12 tests unchanged in isolation; both end-to-end suites also passed (256 server / 70 MCP checks). Restarted full verification, including bundle rebuilds. +- Diagnosed filesystem-watch failures: the unchanged mirror integration suite produced `EMFILE` inside the sandbox despite a 1,048,575 descriptor limit, then passed all 3 tests outside it. Stopped only this worktree’s already-failed sandboxed test process and reran full foreground verification outside the sandbox after automatic approval. No tests or assertions were disabled. +- Minimal kit registration is intentional, not a drift-guard exemption. MEET-5 replaces the placeholder; notes are preserved but not yet displayed/editable in it. +- No unresolved MEET-4 questions. MEET-5/6/7 must follow the contract above, including epoch vs relative timestamps and whole-array replacement. Recording controls, transcription transport, and large-transcript storage policy remain later work. From 490c499c4e77b308e2fd711bfbd5e713cf5d2626 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sun, 4 Oct 2026 03:06:34 +0800 Subject: [PATCH 3/3] chore: drop worker docs (MEET-4) --- _brief.md | 32 -------------------- _report.md | 87 ------------------------------------------------------ 2 files changed, 119 deletions(-) delete mode 100644 _brief.md delete mode 100644 _report.md diff --git a/_brief.md b/_brief.md deleted file mode 100644 index 81d6e5cf..00000000 --- a/_brief.md +++ /dev/null @@ -1,32 +0,0 @@ -# MEET-4 — Block catalogue + prop schemas + MCP surface for `meeting` - -You are a Worker agent on the OpenBook team. Work ONLY in this worktree (`/Users/eliot/Workspaces/OpenBook-wt-meet-4`, branch `feat/meet-4-meeting-catalogue`). Do NOT push. Do NOT touch main. Conventional commits (`feat(sdk,mcp): … (MEET-4)`). - -## Task -Register a new `meeting` block type in the single-source catalogue so sdk/mcp/server stay drift-free. The UI view lands later (MEET-5) — your job is the type system + MCP/agent surface + docs. Anchors (recon-time hints — trust the code): -- `CATALOGUE_LITERAL` `packages/sdk/src/blockCatalogue.ts:62-113` — entries `{type, category, nature, parent?, props, kitValue?, hint}`. Study how `form` (kit) is declared; mirror its decisions (commit 63969ae3 / FORM-3 is the template epic). -- Prop schemas: `packages/sdk/src/blockPropSchemas.ts` `fields` :51-75 + `CATALOGUE_LITERAL` entry; exports `BLOCK_PROP_SCHEMAS`/`BLOCK_PROP_JSON_SCHEMAS`. -- MCP: `packages/mcp/src/server.ts` picks types up from the catalogue; update `mcp/README.md` prop table (enforced by `mcp/scripts/readme.test.mts` + `blockTypes.test.mts`); regenerate `docs/audits/block-api-coverage.md` via `mcp/scripts/coverage.test.mts` (EXEMPT entries need a written reason — avoid unless truly necessary). -- Server agent validators: `server/src/ai/agentBlockTypes.test.ts`. -- IMPORTANT: ui's `registryCatalogue.test.tsx` requires catalogue kit entries ↔ registered custom blocks to match BOTH directions. The ui view doesn't exist yet. If adding `meeting` as a kit entry breaks that guard, register a minimal placeholder kit registration the way the catalogue/tests structurally require OR structure the entry so the guard passes — pick the smallest honest solution and DOCUMENT it in your report (MEET-5 replaces it). Breaking `pnpm verify` is not acceptable. - -## Representation decision (you make it, then document it — MEET-5/6/7 build against it) -Derive prop shapes from what the component will need, favoring existing catalogue idioms (existing tests/conventions win over this brief): -- `status`: 'idle' | 'recording' | 'processing' | 'done' -- audio chunks: ordered list of `{assetId, durationMs, startedAtMs?}` (or parallel arrays if object-arrays aren't idiomatic — check how other blocks store structured props) -- transcript segments: `{startMs, endMs, text}` list — decide props vs child blocks after reading how `form` stores structured kitValue; prefer props unless size/CRDT concerns say otherwise; justify in one paragraph. -- `summary` (string/rich), `startedAt`, `title?` -- manual notes: child blocks (the block should be a container or have a notes container slot — follow the catalogue's `nature`/`parent` idioms, see how form/kit blocks nest). -Write the final representation contract as a short `docs/`-level or in-code doc comment AND in `_report.md` — MEET-5 consumes it verbatim. - -## Acceptance -- `list_block_types` returns `meeting` with propsSchema; `add_blocks` / `update_block_props` validate it; invalid props rejected (test). -- All drift guards green: mcp readme/blockTypes/coverage tests, server agentBlockTypes test, ui registryCatalogue guard. -- Coverage matrix regenerated and committed. - -## Definition of done -- `pnpm verify` green FOREGROUND here, output in report. (Provisioned; artifact-failure symptom → rebuild viewer/server/mcp bundles.) -- All committed, not pushed. `_report.md`: outcome first, head sha, criterion→test map, THE REPRESENTATION CONTRACT, deviations, open questions. Terse. - -## Rules -Never poll external state. Stuck after a real attempt → commit + report. Never delete/weaken existing tests (reviewers check the commit RANGE). diff --git a/_report.md b/_report.md deleted file mode 100644 index 12e57ec2..00000000 --- a/_report.md +++ /dev/null @@ -1,87 +0,0 @@ -# MEET-4 — Drew - -MEET-4 complete. `pnpm verify` passed in the foreground (exit 0); implementation and report committed separately. Not pushed. - -Implementation HEAD: `888f8956cc347a544912c342c7f3d8d77d1c08f6`. The following report-only commit records this verified implementation SHA; final HEAD is the report commit. - -## Criterion → test map - -| Criterion | Evidence | -| --- | --- | -| Catalogue, schemas, defaults/patch semantics, invalid nested values | `packages/sdk/src/blockCatalogue.test.ts` | -| `list_block_types` exposes meeting + propsSchema; MCP creation/update acceptance and rejection | `packages/mcp/scripts/blockTypes.test.mts` (67 checks) | -| Agent `add_blocks` and `update_block_props` validate before suggesting | `packages/server/src/ai/agentBlockTypes.test.ts` (13 tests) | -| Runtime kit registry matches catalogue; notes/props survive UI serialization | `packages/ui/src/blockeditor/registryCatalogue.test.tsx` | -| README and generated matrix drift guards | `packages/mcp/scripts/readme.test.mts`, `coverage.test.mts`; 45 catalogue types + 9 plugin blocks, no exemptions | -| Workspace definition of done | `pnpm verify`, foreground, exit 0 (outside sandbox for filesystem watchers) | - -## Validation output - -Full output: [verification log](/Users/eliot/.bb/thread-storage/meet-4-verify-888f8956.log). Command: `pnpm verify`, foreground, outside sandbox; exit **0**. - -```text -SDK: 32 files, 565 tests passed -UI: 244 files, 2320 tests passed -App: 2 files, 7 tests passed -Server: 97 files, 1327 passed / 6 existing skipped (1333) -MCP: All 45 catalogue types + 9 plugin blocks have MCP API coverage; exemptions: none. -Server e2e: ALL 256 CHECKS PASSED -MCP e2e: ALL 70 CHECKS PASSED -``` - -ESLint-rule tests, builds, generated-file guard, workspace typechecks and lint also passed. Pre-commit ESLint/typechecks and commitlint passed. - -## THE REPRESENTATION CONTRACT - -`meeting` is a `kit` block with `nature: 'container'`, no required parent, and -`kitValue: false`. It publishes no reactive value. Its `children` are ordinary -manual-note blocks (paragraphs, todos, headings, groups, etc.), not transcript -segments. No dedicated notes slot or child-only type is required. - -All top-level props are optional. Missing status means `idle`; missing arrays -mean empty lists; missing summary/title mean empty text; missing startedAt means -not started. These are consumer defaults, not schema-inserted persisted values. -As with other block props, patches shallow-merge; `null` removes a top-level key. -Unknown top-level props remain allowed for forward compatibility. - -| Prop | Stored shape and meaning | -| --- | --- | -| `status` | `'idle' \| 'recording' \| 'processing' \| 'done'`. Validation checks the enum, not state transitions. | -| `audioChunks` | Ordered array of `{assetId: string, durationMs: number, startedAtMs?: number}`. `assetId` is a nonempty asset reference (max 512 characters), never inline audio. `durationMs` is the chunk duration; optional `startedAtMs` is an offset from meeting start, **not** Unix time. Array order is capture/playback order. | -| `transcript` | Ordered array of `{startMs: number, endMs: number, text: string}`. Offsets are relative to meeting start; `endMs >= startMs`. Text is plain text. Array order is display order; overlapping segments are allowed. | -| `summary` | Plain string; no rich-text runs or Markdown interpretation is required. | -| `startedAt` | Unix epoch milliseconds, a finite nonnegative number. | -| `title` | Optional plain string. | - -All durations and offsets are finite nonnegative numbers in milliseconds; -fractional milliseconds are accepted. Every listed nested field is required -except `startedAtMs`; nested objects reject extra keys and null fields. Empty -arrays and empty transcript text are valid. Cross-field `endMs >= startMs` is -checked at runtime and described in the JSON schema (standard JSON Schema cannot -express a comparison to a sibling field). Asset existence, timeline sorting, -status transitions, and transcription size limits are producer responsibilities. - -Audio chunks and transcript segments use structured props, following existing -kit option/rich-run arrays and the form's structured schema prop. They are -machine-produced snapshots, while manual notes need independently editable CRDT -children. Each array is one prop value: updates replace the whole array, with -no per-segment merge guarantee. Recording/transcription producers should batch -updates and serialize writes to avoid concurrent array replacement losing data. -This keeps the first representation small and consistent; large-transcript -storage migration, if needed later, must explicitly version this contract. - -MEET-4 registers a minimal meeting shell through `registerArtifactKit` in both -editor and viewer hosts. It displays title, status, and saved note-block count; -notes remain stored but are not yet rendered/editable inside the shell. It has -no slash-menu entry or recording controls. MEET-5 replaces this renderer and -must render the existing child blocks rather than migrate them into props. - -## Deviations and open questions - -- The first full verification attempt hit transient `EADDRINUSE` on MCP port 4410. The listener had exited on inspection; no process was killed and no test was weakened. -- The new negative test exposed that creation paths previously validated structure but skipped props. Added shared SDK `invalidBlockTreeProps` to MCP creation/append/insert and agent `add_blocks`, validating all declared schemas recursively before writes or suggestions. Existing update validation remains shared. -- Full verification exposed an unrelated dated fixture in `ableOidc.test.ts`: its purportedly valid PAT expired at fixed September 8, 2026 time, while SQL validates against `now()`. Reproduced the 401-versus-302 failure; changed only the fixture to `Date.now() + 60_000`, preserving every assertion. The focused regression passed afterward. -- A later full run ended without a normal summary after export fixture failures and zero-test collection failures while the host disk was nearly full. The export suite (`bookFolder.test.ts`) passed all 12 tests unchanged in isolation; both end-to-end suites also passed (256 server / 70 MCP checks). Restarted full verification, including bundle rebuilds. -- Diagnosed filesystem-watch failures: the unchanged mirror integration suite produced `EMFILE` inside the sandbox despite a 1,048,575 descriptor limit, then passed all 3 tests outside it. Stopped only this worktree’s already-failed sandboxed test process and reran full foreground verification outside the sandbox after automatic approval. No tests or assertions were disabled. -- Minimal kit registration is intentional, not a drift-guard exemption. MEET-5 replaces the placeholder; notes are preserved but not yet displayed/editable in it. -- No unresolved MEET-4 questions. MEET-5/6/7 must follow the contract above, including epoch vs relative timestamps and whole-array replacement. Recording controls, transcription transport, and large-transcript storage policy remain later work.