From 028ce8a38bdc341976af8f9cf67f32b1060d4a26 Mon Sep 17 00:00:00 2001
From: Hayden <154503486+groupthinking@users.noreply.github.com>
Date: Sun, 1 Nov 2026 20:06:45 -0600
Subject: [PATCH 1/2] feat(studio): bind video tasks to sources and proposed
checks
---
apps/web/src/app/page.tsx | 23 +-
.../web/src/components/home/HomePasteForm.tsx | 11 +-
.../web/src/components/home/home-template.css | 6 +
.../components/studio/StudioBoundTasks.tsx | 203 ++++++++++++++++++
.../src/components/studio/StudioIdeOutput.tsx | 9 +-
.../__tests__/StudioBoundTasks.test.tsx | 119 ++++++++++
.../lib/__tests__/home-sell-surface.test.ts | 10 +-
.../studio-bound-tasks.node.test.mjs | 107 +++++++++
apps/web/src/lib/studio-bound-tasks.ts | 101 +++++++++
9 files changed, 565 insertions(+), 24 deletions(-)
create mode 100644 apps/web/src/components/studio/StudioBoundTasks.tsx
create mode 100644 apps/web/src/components/studio/__tests__/StudioBoundTasks.test.tsx
create mode 100644 apps/web/src/lib/__tests__/studio-bound-tasks.node.test.mjs
create mode 100644 apps/web/src/lib/studio-bound-tasks.ts
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index b35afa16d..b93a4b52c 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -13,9 +13,9 @@ import {
import { customBadge } from '@/flags';
export const metadata: Metadata = {
- title: 'UVAI — YouTube URL to Studio',
+ title: 'UVAI — YouTube videos to bound tasks',
description:
- 'Paste a YouTube URL to open a hashed Video Pack in Studio. Transcript quality varies by source.',
+ 'Bind YouTube videos to source-linked tasks, expected outputs, and proposed checks in Studio. Transcript quality varies by source.',
alternates: { canonical: '/' },
};
@@ -49,10 +49,10 @@ export default async function HomePage() {
- Source → context → next step
- A workspace for what comes next.
+ Video → tasks → checks
+ Every action has a source.
- Start with the source. Review the evidence before deciding what to build.
+ Keep the spoken action, expected output, and acceptance check together.
@@ -63,12 +63,12 @@ export default async function HomePage() {
02
Inspect the Video Pack
-
Studio organizes the source into a hashed Video Pack with events, tools, and proposed work.
+ Studio organizes the source into a hashed Video Pack. The Tasks view connects requirements to source timestamps and acceptance criteria.
03
- Review your next move
-
Ask questions and inspect build rails. Proposed work is not a verified deployment.
+ Inspect the checks
+
Review each expected output and export the bound tasks. Unrun checks stay unverified; proposed work is not a verified deployment.
@@ -89,11 +89,12 @@ export default async function HomePage() {
)}
- Your videos know what to do next.
+ Turn YouTube into bound tasks.
- Paste a YouTube URL. UVAI pulls the events, actions, and decisions out of it,
- prepares proposed work and build rails for you to review in Studio.
+ Paste a YouTube URL. UVAI connects source-linked actions, expected outputs,
+ and proposed checks in one workspace. Follow the timestamp, review the task,
+ and take the next step toward your build.
Transcript quality varies by source. No guaranteed production outcome.
diff --git a/apps/web/src/components/home/HomePasteForm.tsx b/apps/web/src/components/home/HomePasteForm.tsx
index aebfb0436..45e543a00 100644
--- a/apps/web/src/components/home/HomePasteForm.tsx
+++ b/apps/web/src/components/home/HomePasteForm.tsx
@@ -1,7 +1,7 @@
'use client';
import { FormEvent, useState } from 'react';
-import { ArrowUp, Link2 } from 'lucide-react';
+import { ArrowRight, Link2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { submitHomePaste } from '@/lib/studio-handoff';
@@ -46,10 +46,11 @@ export default function HomePasteForm() {
/>
-
+ Build from video
+
{error ? (
@@ -58,7 +59,7 @@ export default function HomePasteForm() {
) : (
- Opens Studio and starts the Video Pack handoff. Transcript quality varies by source.
+ Opens Studio to extract a Video Pack and inspect source-linked tasks. Transcript quality varies by source.
)}
diff --git a/apps/web/src/components/home/home-template.css b/apps/web/src/components/home/home-template.css
index 140bdad0a..3ca67ef94 100644
--- a/apps/web/src/components/home/home-template.css
+++ b/apps/web/src/components/home/home-template.css
@@ -11,6 +11,12 @@
.home-source-composer { margin-top: 28px; }
.home-source-composer label { font-size: 10px; text-align: center; }
.home-source-composer form > p { text-align: center; line-height: 1.5; }
+.template-source-input .home-build-button { width: auto; min-width: fit-content; padding: 0 14px; gap: 8px; border-radius: 12px; font-size: 12px; font-weight: 600; white-space: nowrap; }
+.home-build-button:focus-visible { outline: 2px solid var(--uvai-accent); outline-offset: 4px; }
+@media (max-width: 480px) {
+ .template-source-input:has(.home-build-button) { flex-wrap: wrap; padding: 10px; }
+ .template-source-input .home-build-button { width: 100%; min-height: 40px; margin-top: 4px; }
+}
@media (max-width: 1023px) {
.home-template-shell { grid-template-columns: minmax(0, 1fr); min-height: auto; }
.home-template-primary { padding: 32px 4px; }
diff --git a/apps/web/src/components/studio/StudioBoundTasks.tsx b/apps/web/src/components/studio/StudioBoundTasks.tsx
new file mode 100644
index 000000000..bed911718
--- /dev/null
+++ b/apps/web/src/components/studio/StudioBoundTasks.tsx
@@ -0,0 +1,203 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import type { Video } from '@/store/dashboard-types';
+import { hashReviewContent, inspectGroundedSpec, type ReviewInspection } from '@/lib/grounded-build-spec';
+import { projectStudioBoundTasks, type StudioBoundTask } from '@/lib/studio-bound-tasks';
+
+type Pack = NonNullable['pack'];
+
+function timestamp(seconds: number): string {
+ const whole = Math.floor(seconds);
+ const hours = Math.floor(whole / 3600);
+ const minutes = Math.floor((whole % 3600) / 60);
+ const tail = String(whole % 60).padStart(2, '0');
+ return hours ? `${hours}:${String(minutes).padStart(2, '0')}:${tail}` : `${minutes}:${tail}`;
+}
+
+/** A source-bound planning view. No action or check is executed by this panel. */
+export default function StudioBoundTasks({ pack }: { pack?: Pack }) {
+ const inspection = useMemo(() => (
+ pack ? inspectGroundedSpec(pack) : { status: 'unavailable', issues: [] }
+ ), [pack]);
+ const view = useMemo(() => projectStudioBoundTasks(inspection), [inspection]);
+ const [digest, setDigest] = useState<{ inspection: ReviewInspection; status: 'valid' | 'mismatch' | 'unavailable' } | null>(null);
+ const [exportError, setExportError] = useState('');
+
+ useEffect(() => {
+ if (inspection.status !== 'available') return;
+ let current = true;
+ void hashReviewContent(inspection.canonical).then((hash) => {
+ if (current) setDigest({ inspection, status: hash === inspection.contentHash ? 'valid' : 'mismatch' });
+ }).catch(() => {
+ if (current) setDigest({ inspection, status: 'unavailable' });
+ });
+ return () => { current = false; };
+ }, [inspection]);
+ const digestStatus = digest?.inspection === inspection ? digest.status : 'checking';
+
+ function exportTasks() {
+ if (view.status !== 'available' || digestStatus !== 'valid' || !view.tasks.length) return;
+ let url: string | undefined;
+ try {
+ const blob = new Blob([JSON.stringify({
+ version: 'uvai-bound-tasks/v1',
+ kind: 'proposed-work',
+ ...view,
+ }, null, 2)], { type: 'application/json' });
+ url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `uvai-tasks-${view.source.videoId}.json`;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ setExportError('');
+ } catch {
+ setExportError('The task file could not be downloaded. Please try again.');
+ } finally {
+ if (url) {
+ const downloadUrl = url;
+ // Give the browser time to consume the download before releasing it.
+ window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
+ }
+ }
+ }
+
+ if (view.status !== 'available') {
+ return (
+
+ Every task starts with its source.
+
+ {view.status === 'invalid'
+ ? 'The task bindings could not be validated. Review the source issues before continuing.'
+ : view.status === 'source-unavailable'
+ ? 'The source is unavailable. A usable Video Pack is needed to bind tasks and checks.'
+ : 'Run a YouTube video to inspect source-linked tasks, expected outputs, and proposed checks here.'}
+
+ No tasks have been executed or independently checked by this view.
+ {view.issues.length > 0 && (
+
+ {view.issues.map((issue, index) => {issue.message} )}
+
+ )}
+
+ );
+ }
+
+ if (digestStatus !== 'valid') {
+ return (
+
+ Checking task bindings
+
+ {digestStatus === 'checking' ? 'Checking exact source and specification content…'
+ : digestStatus === 'mismatch' ? 'The content digest does not match. Task display and export are held.'
+ : 'Content verification is unavailable. Task display and export are held.'}
+
+
+ );
+ }
+ const blockingIssues = view.issues.filter((issue) => issue.severity === 'blocking');
+ return (
+
+
+
+
+
From source to next action.
+
{view.tasks.length} {view.tasks.length === 1 ? 'task' : 'tasks'} bound to this Video Pack
+
+
+ Export tasks
+
+
+
+ Source-linked proposals. Actions have not run, outputs have not been generated, and independent checks have not been performed by this view.
+
+ {exportError && {exportError}
}
+
+ Source identity
+
+
Video Pack {view.source.packId}
+
Source hash {view.source.sourceHash}
+
Specification hash {view.contentHash}
+
+
+ {view.issues.some((issue) => issue.severity === 'warning') && (
+
+ Source limitations
+
+ {view.issues.filter((issue) => issue.severity === 'warning').map((issue, index) => (
+ {issue.message}
+ ))}
+
+
+ )}
+
+ {blockingIssues.length > 0 && (
+
+
Resolve before execution
+
+ {blockingIssues.map((issue, index) => {issue.message} )}
+
+
+ )}
+ {view.tasks.length === 0 ? This specification contains no tasks.
: (
+
+ {view.tasks.map((task, index) => )}
+
+ )}
+
+ );
+}
+
+function TaskRow({ task, index }: { task: StudioBoundTask; index: number }) {
+ return (
+
+
+
+ {String(index + 1).padStart(2, '0')} · {task.id}
+ {task.classification} · {task.required ? 'Required' : 'Optional'}
+
+
{task.title}
+ {task.detail &&
{task.detail}
}
+
+
+
+
Source
+ {task.citations.length === 0 ?
No direct source citation. This task needs review.
: (
+
+ )}
+
+
+
Expected output & checks
+ {task.checks.length === 0 ?
No acceptance criteria supplied. Define a check before execution.
: (
+
+ {task.checks.map((check) => (
+
+ {check.id} Not run
+
+
Given {check.given}
+
When {check.when}
+
Then {check.then}
+
+
+ ))}
+
+ )}
+
+
Action: not run · Output: not generated · Independent check: unverified
+
+
+ );
+}
diff --git a/apps/web/src/components/studio/StudioIdeOutput.tsx b/apps/web/src/components/studio/StudioIdeOutput.tsx
index ae4cc5b42..1194c82d1 100644
--- a/apps/web/src/components/studio/StudioIdeOutput.tsx
+++ b/apps/web/src/components/studio/StudioIdeOutput.tsx
@@ -5,6 +5,7 @@ import type { Video } from '@/store/dashboard-types';
import type { ExtractedEvent } from '@/lib/types';
import { studioActionCard } from '@/lib/studio-pipeline-status';
import type { StudioStackCheckView } from '@/lib/official-templates';
+import StudioBoundTasks from './StudioBoundTasks';
export type StudioReviewActionInput = {
tool: string;
@@ -12,9 +13,10 @@ export type StudioReviewActionInput = {
result?: string;
};
-type OutputSection = 'events' | 'lingo' | 'tools' | 'intent' | 'signals' | 'spec' | 'transcript';
+type OutputSection = 'tasks' | 'events' | 'lingo' | 'tools' | 'intent' | 'signals' | 'spec' | 'transcript';
const SECTIONS: Array<{ id: OutputSection; label: string }> = [
+ { id: 'tasks', label: 'Tasks' },
{ id: 'events', label: 'Events' },
{ id: 'lingo', label: 'Lingo' },
{ id: 'tools', label: 'Tools' },
@@ -50,7 +52,7 @@ export default function StudioIdeOutput({
stackChecks?: StudioStackCheckView[];
reviewActions?: StudioReviewActionInput[];
}) {
- const [active, setActive] = useState('events');
+ const [active, setActive] = useState('tasks');
const hasContent = Boolean(
video?.videoPack ||
@@ -100,7 +102,7 @@ export default function StudioIdeOutput({
- {!hasContent && active !== 'transcript' ? (
+ {!hasContent && active !== 'transcript' && active !== 'tasks' ? (
Run a video and the extracted actions land here
@@ -109,6 +111,7 @@ export default function StudioIdeOutput({
) : (
<>
+ {active === 'tasks' &&
}
{active === 'events' && (
{events.length === 0 ? (
diff --git a/apps/web/src/components/studio/__tests__/StudioBoundTasks.test.tsx b/apps/web/src/components/studio/__tests__/StudioBoundTasks.test.tsx
new file mode 100644
index 000000000..87df4ca6b
--- /dev/null
+++ b/apps/web/src/components/studio/__tests__/StudioBoundTasks.test.tsx
@@ -0,0 +1,119 @@
+// @vitest-environment jsdom
+import React from 'react';
+import { webcrypto } from 'node:crypto';
+import { Blob as NodeBlob } from 'node:buffer';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import StudioBoundTasks from '@/components/studio/StudioBoundTasks';
+import { reviewPackFixture } from '@/test/grounded-spec-fixture';
+
+beforeEach(() => {
+ vi.stubGlobal('React', React);
+ vi.stubGlobal('crypto', webcrypto);
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+async function exportButton() {
+ const button = await screen.findByRole('button', { name: /export tasks/i });
+ await waitFor(() => expect(button.hasAttribute('disabled')).toBe(false));
+ return button;
+}
+
+describe('source-bound tasks (synthetic contract fixtures, not video evidence)', () => {
+ it('binds a requirement to its source and proposed check without claiming execution', async () => {
+ render( );
+
+ expect(await screen.findByText('Mark a task complete')).toBeTruthy();
+ expect(screen.getByText('Toggle the task checkbox.')).toBeTruthy();
+ expect(screen.getByText('Select a task to mark it complete.')).toBeTruthy();
+ const source = screen.getAllByRole('link').find((link) =>
+ link.getAttribute('href') === 'https://www.youtube.com/watch?v=auJzb1D-fag&t=4s');
+ expect(source).toBeTruthy();
+ expect(source?.getAttribute('rel')).toContain('noopener');
+ expect(screen.getByText('An unchecked task')).toBeTruthy();
+ expect(screen.getByText('Select its checkbox')).toBeTruthy();
+ expect(screen.getByText('The task is visibly checked.')).toBeTruthy();
+ expect(screen.getAllByText(/not run/i).length).toBeGreaterThan(0);
+ await exportButton();
+ });
+
+ it.each(['legacy', 'invalid', 'source-unavailable', 'wrong-source'] as const)(
+ 'does not fabricate tasks from a %s pack', (state) => {
+ const pack = reviewPackFixture();
+ if (state === 'legacy') delete pack.grounded_spec;
+ if (state === 'invalid') Reflect.set(pack, 'grounded_spec', { status: 'available', spec: null });
+ if (state === 'source-unavailable') pack.grounded_spec = {
+ status: 'source-unavailable',
+ issues: [{ code: 'no-source', path: '', message: 'No usable source.', severity: 'blocking' }],
+ };
+ if (state === 'wrong-source') pack.video_id = 'jNQXAC9IVRw';
+ render( );
+ expect(screen.queryByText('Mark a task complete')).toBeNull();
+ const button = screen.queryByRole('button', { name: /export tasks/i });
+ expect(button === null || button.hasAttribute('disabled')).toBe(true);
+ },
+ );
+
+ it('prevents exporting a task set after its bound content changes', async () => {
+ const pack = reviewPackFixture();
+ if (pack.grounded_spec?.status !== 'available') throw new Error('Missing test specification');
+ pack.grounded_spec.spec.requirements[0]!.detail = 'Changed after the original content was hashed.';
+ render( );
+ await screen.findByText(/digest.*match|content.*changed|content.*mismatch/i);
+ const button = screen.queryByRole('button', { name: /export tasks/i });
+ expect(button === null || button.hasAttribute('disabled')).toBe(true);
+ });
+
+ it('holds tasks when content hashing is unavailable', async () => {
+ vi.stubGlobal('crypto', { subtle: { digest: vi.fn().mockRejectedValue(new Error('Unavailable')) } });
+ render( );
+ await screen.findByText(/content verification is unavailable/i);
+ expect(screen.queryByText('Mark a task complete')).toBeNull();
+ expect(screen.queryByRole('button', { name: /export tasks/i })).toBeNull();
+ });
+
+ it('does not reuse a previous digest when the selected pack changes', async () => {
+ const view = render( );
+ await exportButton();
+ const next = reviewPackFixture();
+ if (next.grounded_spec?.status !== 'available') throw new Error('Missing test specification');
+ next.grounded_spec.spec.requirements[0]!.detail = 'A new, unverified selection.';
+ view.rerender( );
+ expect(screen.queryByRole('button', { name: /export tasks/i })).toBeNull();
+ expect(screen.queryByText('Mark a task complete')).toBeNull();
+ await screen.findByText(/content digest does not match/i);
+ });
+
+ it('exports the bound identity and unexecuted checks, then releases the download URL', async () => {
+ const createObjectURL = vi.fn<(blob: Blob) => string>(() => 'blob:synthetic-task-export');
+ const revokeObjectURL = vi.fn();
+ const BrowserURL = URL;
+ vi.stubGlobal('URL', class extends BrowserURL {
+ static createObjectURL = createObjectURL;
+ static revokeObjectURL = revokeObjectURL;
+ });
+ vi.stubGlobal('Blob', NodeBlob);
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
+ const network = vi.fn();
+ vi.stubGlobal('fetch', network);
+ const pack = reviewPackFixture();
+ render( );
+ fireEvent.click(await exportButton());
+ expect(createObjectURL).toHaveBeenCalledOnce();
+ const exported = createObjectURL.mock.calls[0]![0] as unknown as NodeBlob;
+ const data = JSON.parse(await exported.text());
+ expect(data.source.packId).toBe(pack.id);
+ expect(data.source.sourceHash).toBe(pack.provenance.source_hash);
+ expect(data.tasks[0]).toMatchObject({
+ id: 'toggle', executionStatus: 'not_run', outputStatus: 'not_generated', verificationStatus: 'unverified',
+ });
+ expect(data.tasks[0].checks[0]).toMatchObject({ id: 'toggle-check', requirementId: 'toggle', status: 'not_run' });
+ await waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith('blob:synthetic-task-export'), { timeout: 2000 });
+ expect(network).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/lib/__tests__/home-sell-surface.test.ts b/apps/web/src/lib/__tests__/home-sell-surface.test.ts
index b96ec7560..56a1c5360 100644
--- a/apps/web/src/lib/__tests__/home-sell-surface.test.ts
+++ b/apps/web/src/lib/__tests__/home-sell-surface.test.ts
@@ -20,9 +20,9 @@ describe('Home is a sell page; Studio is the workbench', () => {
expect(home).not.toContain('OneLoopStudio');
expect(home).not.toContain('Loading studio');
expect(home).toContain('Universal Video Action Intelligence');
- expect(home).toContain('Your videos know what to do next.');
+ expect(home).toContain('Turn YouTube into bound tasks.');
expect(home).toContain('hashed Video Pack');
- expect(home).toContain('prepares proposed work and build rails for you to review in Studio');
+ expect(home).toContain('and proposed checks in one workspace.');
expect(home).toContain('HomePasteForm');
expect(home).toContain('$199');
expect(home).toContain('HomeProCheckout');
@@ -35,8 +35,8 @@ describe('Home is a sell page; Studio is the workbench', () => {
it('presents a responsive YouTube URL-to-Studio conversion hierarchy', () => {
const home = readSource('app/page.tsx');
- expect(home).toContain('Your videos know what to do next.');
- expect(home).toContain('prepares proposed work and build rails for you to review in Studio');
+ expect(home).toContain('Turn YouTube into bound tasks.');
+ expect(home).toContain('and proposed checks in one workspace.');
expect(home).toContain('Transcript quality varies by source.');
expect(home).toContain('HomeTemplateShell');
expect(home).toContain('Inspect the Video Pack');
@@ -52,7 +52,7 @@ describe('Home is a sell page; Studio is the workbench', () => {
const structured = readSource('components/StructuredData.tsx');
const ogAlt = readSource('app/opengraph-image.tsx');
const sellCopy = `${home}\n${checkout}\n${nav}\n${layout}\n${structured}\n${ogAlt}`;
- expect(home).toContain('Your videos know what to do next.');
+ expect(home).toContain('Turn YouTube into bound tasks.');
expect(home).toContain('Transcript quality varies by source.');
expect(home).toContain('Transcript quality varies by source');
expect(home).not.toContain('turns it into something real');
diff --git a/apps/web/src/lib/__tests__/studio-bound-tasks.node.test.mjs b/apps/web/src/lib/__tests__/studio-bound-tasks.node.test.mjs
new file mode 100644
index 000000000..5c66c41c0
--- /dev/null
+++ b/apps/web/src/lib/__tests__/studio-bound-tasks.node.test.mjs
@@ -0,0 +1,107 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { projectStudioBoundTasks } from '../studio-bound-tasks.ts';
+
+// Synthetic specification fixtures exercise the projection contract. They are
+// not extracted video content and are never included in product output.
+function inspectedSpec() {
+ const videoId = 'auJzb1D-fag';
+ const citation = {
+ kind: 'transcript', index: 0, videoId,
+ startSeconds: 4, endSeconds: 8, quote: 'Keep a count in the browser.',
+ };
+ const requirement = {
+ id: 'count', screenId: 'main', title: 'Counter', detail: 'Keep a count.',
+ classification: 'observed', required: true,
+ capabilities: ['local-state'], rationale: '', citations: [citation],
+ };
+ return {
+ status: 'available', contentHash: 'b'.repeat(64), canonical: 'test-fixture',
+ issues: [{ code: 'model-evidence', path: '', message: 'Not independently verified.', severity: 'warning' }],
+ spec: {
+ version: '1', outputClass: 'browser-interactive', sourceStatus: 'full',
+ confidence: 0.99, limitations: [], app: { name: 'Counter', purpose: 'Count' },
+ source: { packId: `vp:v0:${videoId}`, videoId, sourceUrl: `https://www.youtube.com/watch?v=${videoId}`, sourceHash: 'a'.repeat(64) },
+ screens: [{ id: 'main', name: 'Main', purpose: 'Count' }], state: [],
+ requirements: [requirement, { ...requirement, id: 'reset', title: 'Counter', classification: 'proposed', citations: [], rationale: 'Proposed convenience.' }],
+ acceptanceCriteria: [
+ { id: 'reset-check', requirementId: 'reset', given: 'Count is 2', when: 'Reset is clicked', then: 'Count is 0' },
+ { id: 'count-check', requirementId: 'count', given: 'Count is 0', when: 'Increment is clicked', then: 'Count is 1' },
+ ],
+ unresolved: [], unsupported: [],
+ },
+ };
+}
+
+test('binds criteria by requirement ID despite duplicate titles and different order', () => {
+ const view = projectStudioBoundTasks(inspectedSpec());
+ assert.equal(view.status, 'available');
+ assert.deepEqual(view.tasks.map((task) => task.checks.map((check) => check.id)), [['count-check'], ['reset-check']]);
+ assert.equal(view.tasks[0].citations[0].videoId, 'auJzb1D-fag');
+ assert.equal(view.tasks[0].citations[0].startSeconds, 4);
+ assert.equal(view.tasks[0].citations[0].quote, 'Keep a count in the browser.');
+ assert.equal(view.tasks[0].citations[0].sourceUrl, 'https://www.youtube.com/watch?v=auJzb1D-fag&t=4s');
+ assert.deepEqual(view.tasks[1].citations, []);
+ assert.equal(view.source.sourceHash, 'a'.repeat(64));
+ assert.equal(view.contentHash, 'b'.repeat(64));
+});
+
+test('observed source, high confidence and proposed criteria never imply executed or verified', () => {
+ const view = projectStudioBoundTasks(inspectedSpec());
+ for (const task of view.tasks) {
+ assert.equal(task.executionStatus, 'not_run');
+ assert.equal(task.outputStatus, 'not_generated');
+ assert.equal(task.verificationStatus, 'unverified');
+ assert.ok(task.checks.every((check) => check.status === 'not_run'));
+ }
+ assert.equal(view.tasks[1].classification, 'proposed');
+});
+
+test('invalid, absent or source-unavailable inspections expose no task rows', () => {
+ for (const status of ['invalid', 'unavailable', 'source-unavailable']) {
+ const issues = [{ code: 'invalid-citation', path: 'count', message: 'Wrong video.', severity: 'blocking' }];
+ // Extra stale data must not survive the inspection's failed status.
+ const view = projectStudioBoundTasks({ ...inspectedSpec(), status, issues });
+ assert.deepEqual(view.tasks, []);
+ assert.deepEqual(view.issues, issues);
+ assert.equal(view.status, status);
+ assert.equal('source' in view, false);
+ }
+});
+
+test('does not manufacture missing criteria, source citations, or tasks', () => {
+ const inspection = inspectedSpec();
+ inspection.spec.acceptanceCriteria = [];
+ inspection.spec.requirements[0].citations = [];
+ let view = projectStudioBoundTasks(inspection);
+ assert.deepEqual(view.tasks[0].checks, []);
+ assert.deepEqual(view.tasks[0].citations, []);
+ inspection.spec.requirements = [];
+ view = projectStudioBoundTasks(inspection);
+ assert.deepEqual(view.tasks, []);
+});
+
+test('projected copies cannot mutate the inspected source or its check text', () => {
+ const inspection = inspectedSpec();
+ const before = structuredClone(inspection);
+ const view = projectStudioBoundTasks(inspection);
+ view.source.sourceHash = 'changed';
+ view.tasks[0].citations[0].quote = 'changed';
+ view.tasks[0].checks[0].then = 'changed';
+ view.tasks[0].issues[0].message = 'changed';
+ view.issues[0].message = 'changed';
+ assert.deepEqual(inspection, before);
+});
+
+test('binds unresolved and unsupported blockers to their affected requirement IDs', () => {
+ const inspection = inspectedSpec();
+ inspection.spec.unresolved = [{ id: 'reset-question', requirementIds: ['reset'], question: 'What resets?', required: true }];
+ inspection.spec.unsupported = [{ id: 'count-storage', requirementIds: ['count'], capability: 'shared-database', reason: 'Needs shared storage.', required: true }];
+ inspection.issues.push(
+ { code: 'unresolved-question', path: 'reset-question', message: 'What resets?', severity: 'blocking' },
+ { code: 'unsupported-capability', path: 'count-storage', message: 'Needs shared storage.', severity: 'blocking' },
+ );
+ const view = projectStudioBoundTasks(inspection);
+ assert.deepEqual(view.tasks[0].issues.map((issue) => issue.code), ['model-evidence', 'unsupported-capability']);
+ assert.deepEqual(view.tasks[1].issues.map((issue) => issue.code), ['model-evidence', 'unresolved-question']);
+});
diff --git a/apps/web/src/lib/studio-bound-tasks.ts b/apps/web/src/lib/studio-bound-tasks.ts
new file mode 100644
index 000000000..f8c34bab8
--- /dev/null
+++ b/apps/web/src/lib/studio-bound-tasks.ts
@@ -0,0 +1,101 @@
+import type { GroundedBuildSpec, ReviewInspection, SpecIssue } from './grounded-build-spec';
+
+export interface StudioBoundTaskCheck {
+ id: string;
+ requirementId: string;
+ given: string;
+ when: string;
+ then: string;
+ status: 'not_run';
+}
+
+export type StudioBoundTaskCitation =
+ GroundedBuildSpec['requirements'][number]['citations'][number] & {
+ sourceUrl: string;
+ };
+
+export interface StudioBoundTask {
+ id: string;
+ title: string;
+ detail: string;
+ classification: GroundedBuildSpec['requirements'][number]['classification'];
+ required: boolean;
+ citations: StudioBoundTaskCitation[];
+ checks: StudioBoundTaskCheck[];
+ issues: SpecIssue[];
+ executionStatus: 'not_run';
+ outputStatus: 'not_generated';
+ verificationStatus: 'unverified';
+}
+
+export type StudioBoundTasksView =
+ | {
+ status: 'unavailable' | 'invalid' | 'source-unavailable';
+ tasks: [];
+ issues: SpecIssue[];
+ }
+ | {
+ status: 'available';
+ source: GroundedBuildSpec['source'];
+ contentHash: string;
+ tasks: StudioBoundTask[];
+ issues: SpecIssue[];
+ };
+
+/**
+ * Project inspectGroundedSpec(pack)'s validated result into one task per
+ * requirement. No title/position matching: checks bind by requirementId and
+ * citations stay on their original requirement. This is a read-only view of a
+ * proposed specification, never an execution or independent-check receipt.
+ */
+export function projectStudioBoundTasks(inspection: ReviewInspection): StudioBoundTasksView {
+ if (inspection.status !== 'available') {
+ return {
+ status: inspection.status,
+ tasks: [],
+ issues: inspection.issues.map((item) => ({ ...item })),
+ };
+ }
+
+ const { spec } = inspection;
+ const checksByRequirement = new Map();
+ for (const criterion of spec.acceptanceCriteria) {
+ const checks = checksByRequirement.get(criterion.requirementId) ?? [];
+ checks.push({ ...criterion, status: 'not_run' });
+ checksByRequirement.set(criterion.requirementId, checks);
+ }
+ const issuePathsByRequirement = new Map>();
+ for (const requirement of spec.requirements) {
+ issuePathsByRequirement.set(requirement.id, new Set([requirement.id]));
+ }
+ for (const limitation of [...spec.unresolved, ...spec.unsupported]) {
+ for (const requirementId of limitation.requirementIds) {
+ issuePathsByRequirement.get(requirementId)?.add(limitation.id);
+ }
+ }
+
+ return {
+ status: 'available',
+ source: { ...spec.source },
+ contentHash: inspection.contentHash,
+ issues: inspection.issues.map((item) => ({ ...item })),
+ tasks: spec.requirements.map((requirement) => ({
+ id: requirement.id,
+ title: requirement.title,
+ detail: requirement.detail,
+ classification: requirement.classification,
+ required: requirement.required,
+ citations: requirement.citations.map((citation) => ({
+ ...citation,
+ sourceUrl: `https://www.youtube.com/watch?v=${encodeURIComponent(citation.videoId)}&t=${Math.floor(citation.startSeconds)}s`,
+ })),
+ checks: checksByRequirement.get(requirement.id) ?? [],
+ issues: inspection.issues
+ .filter((item) => !item.path || issuePathsByRequirement.get(requirement.id)?.has(item.path))
+ .map((item) => ({ ...item })),
+ executionStatus: 'not_run',
+ outputStatus: 'not_generated',
+ verificationStatus: 'unverified',
+ })),
+ };
+}
From 0b7d708f2c65b29ddc4b924b5496e727db8c2884 Mon Sep 17 00:00:00 2001
From: Hayden <154503486+groupthinking@users.noreply.github.com>
Date: Mon, 2 Nov 2026 06:12:19 -0600
Subject: [PATCH 2/2] test(home): align landing assertion with bound tasks
headline
---
apps/web/src/lib/__tests__/landing-style-safety.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/lib/__tests__/landing-style-safety.test.ts b/apps/web/src/lib/__tests__/landing-style-safety.test.ts
index 341316d20..10953a66f 100644
--- a/apps/web/src/lib/__tests__/landing-style-safety.test.ts
+++ b/apps/web/src/lib/__tests__/landing-style-safety.test.ts
@@ -13,7 +13,7 @@ describe('landing style safety', () => {
it('keeps one clear heading for the YouTube URL to Studio workflow', () => {
const source = readSource('app/page.tsx');
expect(source.match(/ {