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.

  1. @@ -63,12 +63,12 @@ export default async function HomePage() {
  2. 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.

  • -

    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() { />

    {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

    +
    + +
    +

    + 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__/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(/ { 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', + })), + }; +}