diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 456a493..1b4cec3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -83,6 +83,7 @@ const draft = defineCommand({ report: { type: 'string', description: 'write the full draft QA report to this JSON path' }, findings: { type: 'string', description: 'write check findings JSON to this path' }, 'evidence-dir': { type: 'string', description: 'directory for sampled frame evidence/contact sheet' }, + waive: { type: 'string', description: 'comma-separated QA finding codes the user chose to skip (persisted in qa/waivers.json; evidence-integrity codes are refused)' }, }, async run({ args }) { const r = await renderTool.draft({ @@ -92,6 +93,7 @@ const draft = defineCommand({ reportPath: args.report ? String(args.report) : undefined, findingsPath: args.findings ? String(args.findings) : undefined, frameEvidenceDir: args['evidence-dir'] ? String(args['evidence-dir']) : undefined, + waive: args.waive ? String(args.waive).split(',').map((code) => code.trim()).filter(Boolean) : undefined, onProgress: (c) => process.stderr.write(c), }); printJson(r); @@ -507,6 +509,7 @@ const gate = defineCommand({ gate: { type: 'string', default: 'none', description: 'gate_a | gate_b | gate_c | preview | gate_d' }, decision: { type: 'string', default: 'none', description: 'approve | revise | none' }, scope: { type: 'string', default: 'unknown', description: 'visual_only | gate_b_payload | none | unknown' }, + origin: { type: 'string', default: 'unknown', description: 'who asked for the change: user (current turn names it in the user\'s own words) | model (model-initiated or mixed reply) | unknown' }, recovery: { type: 'string', default: 'unknown', description: 'available | not_available | unknown' }, 'recovery-decision': { type: 'string', default: 'none', description: 'legacy input only: new_visual_revision | pause | none; never emit a new recovery form' }, 'artifact-state': { type: 'string', default: 'unknown', description: 'new | unchanged | changed | unknown' }, @@ -520,6 +523,7 @@ const gate = defineCommand({ gate: String(args.gate) as GateTransitionInput['gate'], decision: String(args.decision) as GateTransitionInput['decision'], scope: String(args.scope) as GateTransitionInput['scope'], + origin: String(args.origin) as GateTransitionInput['origin'], recovery: String(args.recovery) as GateTransitionInput['recovery'], recoveryDecision: String(args['recovery-decision']) as GateTransitionInput['recoveryDecision'], artifactState: String(args['artifact-state']) as GateTransitionInput['artifactState'], diff --git a/packages/core/src/gates/transition.ts b/packages/core/src/gates/transition.ts index a56dfa2..fd224d2 100644 --- a/packages/core/src/gates/transition.ts +++ b/packages/core/src/gates/transition.ts @@ -7,6 +7,12 @@ export type RecoveryState = 'unknown' | 'available' | 'not_available'; export type RecoveryDecision = 'none' | 'new_visual_revision' | 'pause'; export type ArtifactState = 'unknown' | 'new' | 'unchanged' | 'changed'; export type ApprovalStatus = 'unknown' | 'none' | 'pending' | 'approved'; +/** Who asked for the change decides whether to ask again. `user` means the + * CURRENT turn names the change in the user's own words; a model-initiated + * change — or a reply that mixes an instruction with the model's own + * proposal — is `model` (the higher bar wins). No host verifies this in OVS: + * it is the driving agent's honest self-report. */ +export type ChangeOrigin = 'unknown' | 'user' | 'model'; export interface GateTransitionInput { line?: VideoLine; @@ -14,6 +20,7 @@ export interface GateTransitionInput { gate?: GateName; decision?: GateDecision; scope?: RevisionScope; + origin?: ChangeOrigin; recovery?: RecoveryState; recoveryDecision?: RecoveryDecision; artifactState?: ArtifactState; @@ -37,6 +44,7 @@ const VALID = { gate: new Set(['none', 'gate_a', 'gate_b', 'gate_c', 'preview', 'gate_d']), decision: new Set(['none', 'approve', 'revise']), scope: new Set(['unknown', 'none', 'visual_only', 'gate_b_payload']), + origin: new Set(['unknown', 'user', 'model']), recovery: new Set(['unknown', 'available', 'not_available']), recoveryDecision: new Set(['none', 'new_visual_revision', 'pause']), artifactState: new Set(['unknown', 'new', 'unchanged', 'changed']), @@ -95,6 +103,7 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi gate: raw.gate ?? 'none', decision: raw.decision ?? 'none', scope: raw.scope ?? 'unknown', + origin: raw.origin ?? 'unknown', recovery: raw.recovery ?? 'unknown', recoveryDecision: raw.recoveryDecision ?? 'none', artifactState: raw.artifactState ?? 'unknown', @@ -106,6 +115,7 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi assertEnum('gate', input.gate, VALID.gate); assertEnum('decision', input.decision, VALID.decision); assertEnum('scope', input.scope, VALID.scope); + assertEnum('origin', input.origin, VALID.origin); assertEnum('recovery', input.recovery, VALID.recovery); assertEnum('recoveryDecision', input.recoveryDecision, VALID.recoveryDecision); assertEnum('artifactState', input.artifactState, VALID.artifactState); @@ -115,6 +125,21 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi } const lineOps = lineOperations(input.line, input.artifact); + // Who asked for the change decides whether to ask again: a change the + // current user turn dictates is applied directly — the instruction is itself + // the authorization, and asking them to confirm a change they just asked for + // costs a full round trip and teaches them their instructions are not taken + // at face value. A mixed reply (instruction + model proposal) is `model`. + if (input.decision === 'revise' && input.scope === 'gate_b_payload' && input.origin === 'user') { + return result({ + nextAction: 'apply_user_instruction_then_approve_plan', + authorities: ['edit_current_artifact', 'approve_gate_b'], + allowedOps: ['edit_current_artifact', 'continue_approved_plan'], + prohibitedOps: NO_VISUAL_RESET, + reason: 'The current user turn names this change in the user\'s own words; that instruction is itself the authorization. Apply exactly that change and re-sign — never ask them to confirm a change they dictated.', + }); + } + // A signed-payload amendment creates a new signature and therefore a fresh // OVS draft-repair cycle. Recovery evidence for the old signature is stale. if (input.decision === 'revise' && input.scope === 'gate_b_payload') { @@ -165,9 +190,9 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi }); } return result({ - nextAction: 'report_visual_qa_blocker', - prohibitedOps: ['emit_form', 'edit_files', ...NO_VISUAL_RESET], - reason: 'Technical QA exhaustion never creates a user authorization form. Wait for a real revision request, which authorizes the next bounded cycle.', + nextAction: 'present_findings_and_ask_user_direction', + prohibitedOps: ['emit_form', 'edit_files', 'restart_visual_qa_cycle', ...NO_VISUAL_RESET], + reason: 'The visual QA cycle is exhausted. Show the current frames and remaining findings, offer another repair round or skipping the named check, and end the turn — the user\'s reply grants the next cycle. Then make a materially different edit: the failed strategies are recorded, and repeating one spends the new budget for nothing.', }); } @@ -263,7 +288,7 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi authorities: ['edit_current_artifact', 'restart_visual_qa_cycle'], allowedOps: lineOps.edit, prohibitedOps: ['emit_form', ...NO_VISUAL_RESET], - reason: 'Consume the legacy recovery submission once. New turns use the original revise decision and OVS content-signature reset.', + reason: 'Consume the legacy recovery submission once — by making a materially different edit, never by repeating a strategy the recorded evidence already shows failed.', }); } if (input.recovery === 'unknown') { @@ -285,9 +310,9 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi if (input.recovery === 'available') { return result({ - nextAction: 'report_visual_qa_blocker', - prohibitedOps: ['emit_form', 'edit_files', ...NO_VISUAL_RESET], - reason: 'Technical QA exhaustion is not a separate user decision. Report the blocker and wait for a real revision request; never emit a recovery form.', + nextAction: 'present_findings_and_ask_user_direction', + prohibitedOps: ['emit_form', 'edit_files', 'restart_visual_qa_cycle', ...NO_VISUAL_RESET], + reason: 'An exhausted visual QA cycle is a user fork, not a silent wait: show the current frames and remaining findings, offer another repair round or skipping the named check (`ovs draft --waive `), and end the turn. The user\'s reply grants the next cycle; never restart one as the silent default.', }); } diff --git a/packages/core/test/gate-transition.test.ts b/packages/core/test/gate-transition.test.ts index f78e2ba..96c5e4a 100644 --- a/packages/core/test/gate-transition.test.ts +++ b/packages/core/test/gate-transition.test.ts @@ -73,15 +73,56 @@ describe('resolveGateTransition', () => { expect(result.prohibited_ops).toContain('emit_form'); }); - it('reports an exhausted QA blocker without creating a recovery form', () => { + it('turns an exhausted QA cycle into a user fork, not a silent wait', () => { const result = resolveGateTransition({ line: 'compose', artifact: 'composition', recovery: 'available', errorCode: 'E_VISUAL_REVISION_EXPLICIT_AUTHORIZATION_REQUIRED', }); - expect(result).toMatchObject({ next_action: 'report_visual_qa_blocker', form: null }); + expect(result).toMatchObject({ next_action: 'present_findings_and_ask_user_direction', form: null }); expect(result.prohibited_ops).toContain('emit_form'); + expect(result.prohibited_ops).toContain('edit_files'); + expect(result.prohibited_ops).toContain('restart_visual_qa_cycle'); + expect(result.reason).toMatch(/materially different edit/i); + }); + + it('offers the fork on bare exhausted recovery with no decision at all', () => { + const result = resolveGateTransition({ + line: 'compose', + artifact: 'composition', + recovery: 'available', + }); + expect(result).toMatchObject({ next_action: 'present_findings_and_ask_user_direction', form: null }); + expect(result.reason).toMatch(/waive|skipping/i); + }); + + it('applies a user-dictated amendment directly instead of re-confirming it', () => { + const result = resolveGateTransition({ + line: 'compose', + artifact: 'composition', + gate: 'gate_b', + decision: 'revise', + scope: 'gate_b_payload', + origin: 'user', + }); + expect(result).toMatchObject({ next_action: 'apply_user_instruction_then_approve_plan', form: null }); + expect(result.authorities).toContain('approve_gate_b'); + expect(result.reason).toMatch(/own words/i); + }); + + it('keeps the Gate B amendment for model-initiated or mixed changes', () => { + for (const origin of ['model', 'unknown'] as const) { + const result = resolveGateTransition({ + line: 'compose', + artifact: 'composition', + gate: 'gate_b', + decision: 'revise', + scope: 'gate_b_payload', + origin, + }); + expect(result.next_action).toBe('open_gate_b_amendment'); + } }); it('rejects mixed current and legacy decision fields', () => { diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 04a3a59..9b3a607 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -66,8 +66,9 @@ server.tool( report_path: z.string().optional(), findings_path: z.string().optional(), frame_evidence_dir: z.string().optional(), + waive: z.array(z.string()).optional().describe('QA finding codes the user chose to skip; persisted so later phases never re-block on them'), }, - ({ project, out, quality, report_path, findings_path, frame_evidence_dir }) => + ({ project, out, quality, report_path, findings_path, frame_evidence_dir, waive }) => format(renderTool.draft({ project, output: out, @@ -75,6 +76,7 @@ server.tool( reportPath: report_path, findingsPath: findings_path, frameEvidenceDir: frame_evidence_dir, + waive, onProgress: toStderr, })), ); @@ -212,6 +214,7 @@ server.tool( gate: z.enum(['none', 'gate_a', 'gate_b', 'gate_c', 'preview', 'gate_d']).optional(), decision: z.enum(['none', 'approve', 'revise']).optional(), scope: z.enum(['unknown', 'none', 'visual_only', 'gate_b_payload']).optional(), + origin: z.enum(['unknown', 'user', 'model']).optional().describe('who asked for the change: user = the current turn names it in the user\'s own words; a mixed reply is model'), recovery: z.enum(['unknown', 'available', 'not_available']).optional(), recoveryDecision: z.enum(['none', 'new_visual_revision', 'pause']).optional(), artifactState: z.enum(['unknown', 'new', 'unchanged', 'changed']).optional(), diff --git a/packages/skills/gate-control/SKILL.md b/packages/skills/gate-control/SKILL.md index 205ea1d..64d962a 100644 --- a/packages/skills/gate-control/SKILL.md +++ b/packages/skills/gate-control/SKILL.md @@ -27,6 +27,8 @@ Keep `Gate A/B/C/D` and `HTML Preview` for tool calls, stored state, and technic Every gate shows the current artifact, a concise next-action/cost/QA note, one decision request, and then stops. A new turn, question, or unrelated message is not approval. +Gate B's review package opens with the locked direction summary — line, aspect, duration, video language, audio mode, supplied-asset usage, and any billable cost note. Do not re-ask a direction fact the user already settled: the plan confirmation restates it, it does not reopen it. A reply naming a different language is a revise instruction — rewrite the plan artifacts to that language and show the one updated confirmation; never approve artifacts that do not match the language you showed. + | Gate | Required artifact | Stable decision field | Approval authorizes | | --- | --- | --- | --- | | Gate B | script + shotlist or `plan.json` summary, including narration profile | `gate_b_decision` | production from that exact plan | @@ -42,10 +44,12 @@ Gate C is one batch-level decision. A pending or failed paid request is not reus - A Preview/Gate D `revise` authorizes editing the displayed artifact within the requested scope and any required non-billable restart of its visual-QA cycle. - `approve` authorizes only the displayed artifact and next transition. -- Technical QA exhaustion is not a second user decision and must never create a new recovery form. +- An exhausted visual-QA cycle is a user fork, not a silent wait and not a form: show the current frames and the remaining findings, offer another repair round or skipping the named check, and end the turn. The user's reply grants the next cycle — never restart one as the silent default, and then make a materially different edit: the failed strategies are recorded, and repeating one spends the new budget for nothing. +- Whenever a quality finding blocks progress, tell the user in plain language what the check flagged and that they may skip it if they accept the look — they cannot choose an option they were never told exists. Skip with `ovs draft --waive `; the waiver persists on the project, so never ask them to skip the same check twice. Evidence-integrity findings (missing or corrupt frames/maps, parse failures) are repaired, not offered. - Legacy `visual_recovery_decision=new_visual_revision` input remains consumable for old clients, but must not be emitted in a new task. - An error that says authorization is required does not itself prove recovery availability; query durable status first. - A malformed local payload, missing file, stale evidence, failed check, or write error is system work, not a creative decision. Repair it without creating a gate when approved intent is unchanged. +- A recovery is executed, not narrated: a valid recovery trace contains the concrete file mutation BEFORE the validator retry. Re-running an unchanged validation is not progress, and a diagnosis-only response at that point is incomplete work, not a finished turn. When production or rendering tools are explicitly unavailable, return a clearly unexecuted production package for an otherwise clear brief: assumptions, complete narration/script, timed storyboard, exact visible copy/captions, visual/audio and rights-safe asset plan, export target, preview checklist, and final playback/encoding QA. Do not claim files exist or withhold the package behind a direction form. @@ -59,8 +63,9 @@ After a gate submission, a post-gate edit, a resumed turn with prior approval, o - `visual_only`: HTML/CSS/SVG/layout/motion/palette/assets; no approved wording, timing, language, narration, delivery, source mapping, role, or provider-setting change. - `gate_b_payload`: approved copy/casing/punctuation, timing, language, narration, delivery, source mapping, semantic roles, or signed provider intent. - `unknown`: inspect the requested files before asking a technical question. -4. Set recovery only from deterministic evidence: `available`, `not_available`, or `unknown`. This selects internal control flow, not a new form. -5. Run `ovs gate transition` and obey `next_action`, `form`, `allowed_ops`, and `prohibited_ops`. +4. Classify who asked for the change (`--origin`). `user` means the CURRENT turn names the change in the user's own words — the resolver then returns `apply_user_instruction_then_approve_plan`: apply exactly that change and re-sign, and never ask them to confirm a change they dictated (that costs a full round trip and teaches them their instructions are not taken at face value). A model-initiated change, or a reply that mixes an instruction with your own proposal, is `model` — the higher bar wins. Nothing verifies `origin`; report it honestly. +5. Set recovery only from deterministic evidence: `available`, `not_available`, or `unknown`. This selects internal control flow, not a new form. +6. Run `ovs gate transition` and obey `next_action`, `form`, `allowed_ops`, and `prohibited_ops`. Always invoke the resolver through the public `ovs gate transition` command (or the equivalent `gate_transition` MCP tool). Never execute a resolver by referencing an installed skill or Marketplace path directly. Pass only the decision field present in the current user submission. Never combine a current `--decision` with a cached `--recovery-decision`. @@ -75,22 +80,22 @@ ovs gate transition \ --recovery not_available ``` -Optional evidence inputs are `--error-code`, `--artifact-state`, and `--approval-status`. `--recovery-decision` is backward-compatible input for an already-visible old form only. Use `unknown` when evidence is missing; never guess `available`. +Optional evidence inputs are `--error-code`, `--artifact-state`, `--approval-status`, and `--origin` (step 4). `--recovery-decision` is backward-compatible input for an already-visible old form only. Use `unknown` when evidence is missing; never guess `available` — and never claim `--origin user` for a change the user did not name in the current turn. ## Invariants - A Preview/Gate D `visual_only` revision with recovery `not_available` goes directly to a localized edit and deterministic QA. It emits no recovery question. - The same revision with recovery `available` still emits no form: make the localized edit, then use `ovs check`, `ovs snapshot`, and `ovs draft`. OVS automatically starts a fresh persisted repair cycle after the authored content signature changes. -- A `gate_b_payload` revision creates exactly one Gate B amendment. Its approved signature starts a fresh QA cycle, so recovery from the old signature is irrelevant and must not be combined into the form. +- A `gate_b_payload` revision creates exactly one Gate B amendment. Its approved signature starts a fresh QA cycle, so recovery from the old signature is irrelevant and must not be combined into the form. The aftermath follows the visual identity: a narration-only amendment keeps the prior silent preview and its go-ahead (scene windows and pixels unchanged), while a visual amendment clears preview evidence — re-run visual QA and the preview only when the visual identity changed. - An unchanged artifact with recorded approval continues from that approval; never ask again merely because the task resumed. - A passing snapshot may create one Preview Gate. A passing draft may create one Gate D. No status check, advisory, retry, or bookkeeping step creates a user gate. - A content edit changes the draft signature and starts a fresh bounded repair cycle automatically. There is no public/manual reset operation; do not delete QA state by hand. - One user decision may produce at most one follow-up authorization request, and only for authority that decision did not already grant. -- `E_VISUAL_REVISION_EXPLICIT_AUTHORIZATION_REQUIRED` never justifies a form. With recovery `unknown`, query status; with recovery `available` and no current revise decision, report the blocker and wait for the next real revision request. +- `E_VISUAL_REVISION_EXPLICIT_AUTHORIZATION_REQUIRED` never justifies a form. With recovery `unknown`, query status; with recovery `available` and no current revise decision, present the frames and remaining findings with the real choices — another repair round, or skipping the named check — and end the turn. Never a reason-only blocker or a generic "how should I proceed?". - After final-video approval, a local visual-only revision reuses the approved plan, assets, and narration. Edit only the affected scene, run `ovs check` and `ovs snapshot`, then encode the revised final; do not ask for production-plan confirmation again or repeat TTS/generation unless the requested scope changes signed content or provider intent. ## Signed amendments For a Gate B amendment, apply only the approved bounded patch, revalidate the changed plan/artifact, then continue through the real Preview/Gate D path. A current Gate B approval wins over cached approval for the old signature. Do not promise an immediate render when a newly materialized preview still needs review. -Status checks, plan bookkeeping, advisory QA, repair passes that remain, QA-cycle restart, and tool misuse errors never create a gate. Never emit `visual_recovery_decision` in new VideoStudio output. +Status checks, plan bookkeeping, advisory QA, repair passes that remain, and tool misuse errors never stop for the user. An exhausted visual-QA cycle does: show its evidence and choices once and wait. Never emit `visual_recovery_decision` in new VideoStudio output. diff --git a/packages/skills/orchestration/SKILL.md b/packages/skills/orchestration/SKILL.md index 472a2c4..51dd74c 100644 --- a/packages/skills/orchestration/SKILL.md +++ b/packages/skills/orchestration/SKILL.md @@ -11,9 +11,17 @@ Read `gate-control` once before the first user gate. It is the single authorizat ## Checkpoint protocol — how every GATE works (there is no special form UI) -1. **Show the artifact in chat** so the user can actually see it — script/plan as markdown, images inline, a draft video as its output file path — plus one line of "what I'll do next" and any cost/QA note. +**Show the work; stop only five times.** The stops are a closed set: the direction (Gate A), the production plan (Gate B), paid generation (Gate C), the visual preview, and the final video (Gate D). Showing an artifact is not an ending in itself — publish it in chat and keep going — EXCEPT at the five stops, where the artifact IS the question. + +1. **Show the artifact in chat** so the user can actually see it — script/plan as markdown, images inline, a draft video as its output file path — plus one line of "what I'll do next" and any cost/QA note. Deliver it in the message that ENDS the turn: a completed turn keeps only your final message, so frames or artifacts posted mid-turn were never seen by the user. 2. **State the options** for that gate and **WAIT for the user to reply**. Do not run the next production step in the same turn as the gate. -3. **On reply, resolve the choice** with `ovs gate transition`: approve → continue only with the returned operation; revise → redo only the authorized scope, re-show, and re-gate only when the resolver says so; abort → stop. Never pass a gate without explicit user confirmation, and never ask again for an unchanged artifact whose approval is already recorded. +3. **On reply, resolve the choice** with `ovs gate transition`: approve → continue only with the returned operation; revise → redo only the authorized scope, re-show, and re-gate only when the resolver says so; abort → stop. Never pass a gate without explicit user confirmation, and never ask again for an unchanged artifact whose approval is already recorded. A reply picking an option you just enumerated IS that decision — `2`, its wording, or a paraphrase — so act on it in the same turn instead of asking them to restate a choice they already made. + +**The visual preview stops once per visual identity.** A change to visible copy, layout, assets, scene order/windows, or motion creates a new identity: capture and show that changed frame set once. Narration text, voice, audio, or narration-timing changes preserve the prior silent frames and their go-ahead when the scene windows and visible output are unchanged — do not re-ask about frames the user already accepted. Frames are presented once as the complete set; rendering before the user has seen them wastes the cheapest correction point. + +**Everything you write outside a tool call is user-facing copy**, including progress/process notes before the final message. Keep internal identifiers, finding codes, severity words (`advisory`, `warning`), and frame-role names out of it — use the localized gate names from `gate-control`. A successful preview message has three parts: lead with the contact sheet/frames, say in one short sentence that the preview is ready, then ask one direct question. Passing checks and non-blocking advisories stay silent. + +**Repair efficiently.** Every blocking finding a single QA result reports is independent: repair all of them in one message, then run the validator once — fixing them one per message turns one round trip into ten. A successful `edit`/write/tool call is authoritative; do not re-read a file to confirm a write landed — the call would have failed. ## 1. Route + lock (read `video-router`) @@ -25,7 +33,9 @@ Classify and LOCK the line (no silent switching): ## 2. GATE A — Proposal (all lines) -Show: the brief you inferred (line, aspect, duration, language) for the user to correct, plus 1–3 differentiated concepts (each: hook + look + rough length; for GENERATE add the shot count and that each clip is a billable call; for AUTO also state the proposed delivery promise — source_led / motion_led / compose_led / hybrid — and the rough segment mix). Options: pick a concept / adjust the brief / new direction. STOP. +**The direction stop comes first, before any plan file exists.** Show: the brief you inferred (line, aspect, duration, language) for the user to correct, plus 2–3 genuinely DIFFERENT concepts (each: hook + look + rough length; for GENERATE add the shot count and that each clip is a billable call; for AUTO also state the proposed delivery promise — source_led / motion_led / compose_led / hybrid — and the rough segment mix). Options: pick a concept / adjust the brief / new direction. STOP. + +Write no script, plan, narration copy, or art direction before their reply — all of that authored against an unchosen direction is work that gets thrown away. A brief that already describes the exact video still stops here, with ONE concept: a misunderstanding costs one message instead of a whole plan. Do not interrogate the user for open creative preferences (casting, audience, style, tone, visual direction) — propose, and let them redirect. ## 2.5 Craft standard (ALL lines — read `video-craft`) @@ -76,6 +86,8 @@ TALKING-HEAD note: if a GENERATE clip already returned lip-synced built-in speec Ingest every supplied clip from evidence (probe + transcribe/OCR-or-frame-reading/extract-frame), author ONE cross-modal `project/plan.json`, `ovs plan validate` and fix every error, **GATE B** on the timeline (`ovs plan summarize`), **GATE C** only if the plan has billable `generate` segments with the exact count and exact `media_kind`/duration/ratio/resolution/audio/reference settings, then assemble per `stage-assemble` (produce each segment via its line, mix narration ONCE, music ducked, burnsubs, normalize loudness). At **GATE D** run `ovs plan promise-check --probe-produced` plus a draft review, then finalize. +**An assembled production is ONE video.** The number of times it stops for the user is fixed by the gate table and never grows with the segment count — seven segments still make exactly one draft review, of the whole video in playback order, never one review per child. An edit to one segment invalidates only that segment: never re-render or re-check an unchanged sibling because another segment changed. + --- ## plan.json as the editable record (all lines) — keep follow-up edits cheap diff --git a/packages/skills/stage-compose/SKILL.md b/packages/skills/stage-compose/SKILL.md index a141230..6aab032 100644 --- a/packages/skills/stage-compose/SKILL.md +++ b/packages/skills/stage-compose/SKILL.md @@ -133,7 +133,7 @@ Canonical minimal `index.html` (16:9, 10s): - **Scenes**: one clip (or a group) per storyboard shot; set each clip's `data-start`/`data-duration` from the shot list so the timeline sums to the brief's duration. - **On-screen text**: keep it inside the frame with padding; large, high-contrast type; one idea per scene. - **Assets**: reference images/footage produced upstream by relative path inside the composition dir (e.g. `./assets/shot1.png`). -- **Timing**: position every tween on the GSAP timeline with an explicit time so it is reproducible; the total of `data-duration` on the root is the final length. +- **Timing**: position every tween from its scene's window via the scaffold's `S("")` / `D("")` helpers (they read the section's `data-start`/`data-duration`), never as a literal second. Narration audio is measured after you author, and `ovs composition reconcile` then moves every scene window — a literal keeps playing against the old window, so the scene captures blank at its QA frames. Relative string positions (`"+=0.5"`, labels) also survive a retime. The total of `data-duration` on the root is the final length. - **SVG-first visual layer**: prefer inline SVG for non-text motion graphics such as diagrams, connectors, nodes, progress paths, charts, orbit lines, icon-like marks, and background geometry. Keep readable prose in normal HTML text boxes unless the SVG text is large, simple, and verified. - **Use GSAP only when time-based motion is needed**: static SVG, CSS layout, and simple held states do not need GSAP. When animation is needed, keep GSAP as the timeline/orchestration layer that animates SVG groups or a small set of HTML containers. - **No remote runtime resources in final HTML**: do not leave CDN scripts, remote fonts, remote images, or remote CSS in the render path. Fetch or copy permitted runtime files into `project/composition/assets/` during authoring, then reference them with relative paths such as `./assets/vendor/gsap.min.js`. If you cannot source a permitted local GSAP/runtime file, report that blocker rather than shipping a network-dependent composition. diff --git a/packages/skills/test/skills-content.test.ts b/packages/skills/test/skills-content.test.ts index 7c37710..0a090ac 100644 --- a/packages/skills/test/skills-content.test.ts +++ b/packages/skills/test/skills-content.test.ts @@ -32,7 +32,15 @@ describe('skill pack content', () => { expect(gate).toContain('content edit changes the draft signature'); expect(gate).toContain('equivalent `gate_transition` MCP tool'); expect(gate).toContain('Never execute a resolver by referencing an installed skill or Marketplace path directly'); - expect(gate).toContain('must never create a new recovery form'); + // The exhausted-cycle contract: a user fork with real choices, never a + // silent wait, never a form, and the skip option is always named. + expect(gate).toContain('user fork, not a silent wait'); + expect(gate).toContain('materially different edit'); + expect(gate).toContain('--waive'); + expect(gate).toContain('they cannot choose an option they were never told exists'); + // Who asked for the change decides whether to ask again. + expect(gate).toContain('apply_user_instruction_then_approve_plan'); + expect(gate).toContain('never ask them to confirm a change they dictated'); expect(gate).toContain('automatically starts a fresh persisted repair cycle'); expect(gate).toContain('Never emit `visual_recovery_decision`'); expect(gate).toContain('Production plan confirmation'); @@ -42,6 +50,27 @@ describe('skill pack content', () => { expect(orchestration).toContain('gate-control'); }); + it('carries the checkpoint craft: five stops, visual identity, direction first', () => { + const orchestration = skill('orchestration'); + const router = skill('video-router'); + const gate = skill('gate-control'); + // The closed stop set + anti-over-stopping rule. + expect(orchestration).toContain('stop only five times'); + expect(orchestration).toContain('Showing an artifact is not an ending in itself'); + // The preview re-gates only on a visual-identity change. + expect(orchestration).toContain('once per visual identity'); + expect(orchestration).toContain('preserve the prior silent frames'); + // The direction stop precedes any plan artifact, in both entry skills. + expect(orchestration).toContain('before any plan file exists'); + expect(router).toContain('Routing ends at the direction stop'); + // An enumerated-option reply is the decision; frames ride the ending message. + expect(orchestration).toContain('IS that decision'); + expect(orchestration).toContain('message that ENDS the turn'); + // Assembled stop economics + amendment aftermath by identity. + expect(orchestration).toContain('never grows with the segment count'); + expect(gate).toContain('re-run visual QA and the preview only when the visual identity changed'); + }); + it('wires the design layers into compose and orchestration', () => { const compose = skill('stage-compose'); const orchestration = skill('orchestration'); diff --git a/packages/skills/video-router/SKILL.md b/packages/skills/video-router/SKILL.md index cffa6e6..6b149d8 100644 --- a/packages/skills/video-router/SKILL.md +++ b/packages/skills/video-router/SKILL.md @@ -39,6 +39,10 @@ Pick a **single line** when one axis cleanly dominates (just trim a clip; just a AUTO does not abandon the axes — it sequences them through one cross-modal plan (`stage-plan` builds the EDL, `stage-assemble` walks it), delegating each segment back to the generate / compose / edit lines. Choosing AUTO is itself the lock: the *primary* still gets named via the plan's `delivery_promise` (source_led / motion_led / compose_led / hybrid). +## Routing ends at the direction boundary + +Routing ends at the direction stop (Gate A), not at a production plan. Present only 2–3 direction concepts plus the facts the brief already locked, and write NO plan file, script, narration copy, or art direction before the user picks a direction — everything authored against an unchosen direction is thrown away when they pick another. + ## Lock the runtime - Decide the primary axis at the brief/proposal stage and **state it in the proposal**. diff --git a/packages/tools/src/composition/scaffold.ts b/packages/tools/src/composition/scaffold.ts index b4f31c5..7c5a184 100644 --- a/packages/tools/src/composition/scaffold.ts +++ b/packages/tools/src/composition/scaffold.ts @@ -82,7 +82,7 @@ export function buildCompositionScaffold(manifest: CompositionManifest): string const timeline = manifest.scenes.map((scene) => { const selector = JSON.stringify(`#scene-${scene.id} .scene-content`); const revealDuration = Math.min(0.6, scene.duration); - return ` tl.fromTo(${selector}, { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: ${revealDuration}, ease: "power3.out" }, ${scene.start});`; + return ` tl.fromTo(${selector}, { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: ${revealDuration}, ease: "power3.out" }, S(${JSON.stringify(scene.id)}));`; }).join('\n'); return ` @@ -111,8 +111,18 @@ ${clips}${audio ? `\n${audio}` : ''} window.__timelines = window.__timelines || {}; const tl = gsap.timeline({ paused: true }); window.__timelines[${JSON.stringify(composition.id)}] = tl; + // Scene windows live on each section's data-start/data-duration, and + // \`ovs composition reconcile\` re-writes those attributes when timing + // changes (e.g. after narration is measured) — which can happen AFTER + // this file is authored. A timeline second written as a literal here + // points at the wrong scene from that moment on. Position every tween + // with S(id)/D(id) and it survives any retiming untouched. + const sceneEl = (id) => document.querySelector('[data-scene-id="' + id + '"]'); + const S = (id) => Number(sceneEl(id).dataset.start); // scene start, seconds + const D = (id) => Number(sceneEl(id).dataset.duration); // scene duration, seconds ${timeline} // Add deterministic scene motion to tl. HyperFrames owns media playback. + // Position tweens from S("") / D(""), never a bare second. })(); @@ -213,13 +223,20 @@ export function reconcileCompositionHtml(html: string, manifest: CompositionMani } // Update only the reveal tweens emitted by buildCompositionScaffold; - // authored tweens and visual structure remain untouched. + // authored tweens and visual structure remain untouched. New scaffolds + // position the reveal from S(id) (which reads the data-start this function + // just rewrote, so it follows the retime by construction); older scaffolds + // carry a literal second and no S()/D() helpers, so they keep the literal + // form — rewriting them to S() would reference an undefined helper. + const hasSceneAnchors = /const S = \(id\)/.test(next); manifest.scenes.forEach((scene) => { const selector = JSON.stringify(`#scene-${scene.id} .scene-content`); const selectorPattern = escapeRegExp(selector); - const reveal = new RegExp(`tl\\.fromTo\\(\\s*${selectorPattern}\\s*,\\s*\\{\\s*opacity\\s*:\\s*0\\s*,\\s*y\\s*:\\s*48\\s*\\}\\s*,\\s*\\{\\s*opacity\\s*:\\s*1\\s*,\\s*y\\s*:\\s*0\\s*,\\s*duration\\s*:\\s*[0-9.]+\\s*,\\s*ease\\s*:\\s*"power3\\.out"\\s*\\}\\s*,\\s*-?[0-9.]+\\s*\\);`); + const anchorPattern = escapeRegExp(`S(${JSON.stringify(scene.id)})`); + const reveal = new RegExp(`tl\\.fromTo\\(\\s*${selectorPattern}\\s*,\\s*\\{\\s*opacity\\s*:\\s*0\\s*,\\s*y\\s*:\\s*48\\s*\\}\\s*,\\s*\\{\\s*opacity\\s*:\\s*1\\s*,\\s*y\\s*:\\s*0\\s*,\\s*duration\\s*:\\s*[0-9.]+\\s*,\\s*ease\\s*:\\s*"power3\\.out"\\s*\\}\\s*,\\s*(?:-?[0-9.]+|${anchorPattern})\\s*\\);`); const revealDuration = Math.min(0.6, scene.duration); - next = next.replace(reveal, `tl.fromTo(${selector}, { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: ${revealDuration}, ease: "power3.out" }, ${scene.start});`); + const position = hasSceneAnchors ? `S(${JSON.stringify(scene.id)})` : String(scene.start); + next = next.replace(reveal, `tl.fromTo(${selector}, { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: ${revealDuration}, ease: "power3.out" }, ${position});`); }); next = next.replace(/window\.__timelines\[(?:"[^"]*"|'[^']*')\]\s*=\s*tl;/, `window.__timelines[${JSON.stringify(manifest.composition.id)}] = tl;`); diff --git a/packages/tools/src/render/composition-qa.ts b/packages/tools/src/render/composition-qa.ts index b7d4f43..6d56a09 100644 --- a/packages/tools/src/render/composition-qa.ts +++ b/packages/tools/src/render/composition-qa.ts @@ -17,8 +17,48 @@ export type Issue = { message: string; fixHint?: string; source?: string; + /** Present when a blocking finding was downgraded by an explicit user + * decision — the finding stays in the report, it just no longer blocks. */ + waived_by_user?: boolean; }; +/** Evidence-integrity findings are repaired, never waived: they mean the QA + * could not see, not that the user accepted a look. Parse failures likewise. */ +export const NON_WAIVABLE_QA_CODES = new Set([ + 'VIDEO_SAMPLE_FRAMES_MISSING', + 'SCENE_MAP_REQUIRED_FOR_SOURCE_ALIGNMENT', +]); + +export function qaFindingIsWaivable(code: string): boolean { + if (NON_WAIVABLE_QA_CODES.has(code)) return false; + return !/_PARSE_FAILED$/.test(code); +} + +/** + * Downgrade user-waived blocking findings to informational. The finding stays + * in the report with its message suffixed, so every later QA phase reports it + * without blocking — the user is never asked to skip the same check twice. + */ +export function applyQaFindingWaivers( + issues: Issue[], + waivedCodes: Iterable, +): { issues: Issue[]; applied: string[] } { + const waived = new Set(waivedCodes); + if (!waived.size) return { issues, applied: [] }; + const applied = new Set(); + const next = issues.map((issue) => { + if (issue.severity !== 'error' || !waived.has(issue.code) || !qaFindingIsWaivable(issue.code)) return issue; + applied.add(issue.code); + return { + ...issue, + severity: 'info' as const, + message: `${issue.message} [skipped by user decision]`, + waived_by_user: true, + }; + }); + return { issues: next, applied: [...applied] }; +} + export type AudioTrack = { absPath: string; startSec: number; @@ -1166,6 +1206,110 @@ export function htmlCopySearch(html: string): (needle: string) => boolean { }; } +const TIMELINE_POSITION_ARG_INDEX: Record = { + set: 2, to: 2, from: 2, fromTo: 3, add: 1, addLabel: 1, call: 2, +}; + +/** Timing tolerance shared with the scene-window checks. */ +const TIMELINE_POSITION_TOLERANCE_SEC = 0.15; +const TIMELINE_POSITION_MAX_REPORTED = 60; + +export type AuthoredAbsolutePosition = { + method: string; + seconds: number; + line: number; + suggestion: string; + /** The scene whose window contains the literal — the one `suggestion` + * offsets from. `scenes` is non-empty by the guard, so there is always one. */ + scene_id: string; +}; + +/** Split one call's top-level arguments, respecting nesting and strings. */ +function splitCallArguments(source: string, openIndex: number): { args: string[]; endIndex: number } | null { + const args: string[] = []; + let depth = 0; + let quote = ''; + let current = ''; + for (let i = openIndex; i < source.length; i++) { + const ch = source[i]; + if (quote) { + current += ch; + if (ch === '\\') { current += source[++i] ?? ''; continue; } + if (ch === quote) quote = ''; + continue; + } + if (ch === '"' || ch === "'" || ch === '`') { quote = ch; current += ch; continue; } + if (ch === '(' || ch === '[' || ch === '{') { + depth++; + if (depth === 1) continue; + } else if (ch === ')' || ch === ']' || ch === '}') { + depth--; + if (depth === 0) { args.push(current.trim()); return { args, endIndex: i }; } + } else if (ch === ',' && depth === 1) { + args.push(current.trim()); + current = ''; + continue; + } + if (depth >= 1) current += ch; + } + return null; +} + +/** + * Timeline positions written as absolute seconds instead of `S(id)` offsets. + * + * The scene windows these literals encode can be recomputed after the HTML is + * authored (`ovs composition reconcile` rewrites every section's data-start + * when timing changes, e.g. once narration is measured) — the scaffold's own + * reveal follows because it is positioned from S(id), but an authored literal + * keeps playing against the old window. The checker knows every window, so it + * hands back the exact replacement expression rather than only the complaint. + */ +export function authoredAbsoluteTimelinePositions( + html: string, + scenes: { id: string; start: number; duration: number }[], +): AuthoredAbsolutePosition[] { + const found: AuthoredAbsolutePosition[] = []; + if (!scenes.length) return found; + const scriptRe = /]*>([\s\S]*?)<\/script>/gi; + let scriptMatch: RegExpExecArray | null; + while ((scriptMatch = scriptRe.exec(html)) !== null) { + const script = scriptMatch[1]; + const scriptOffset = scriptMatch.index + scriptMatch[0].indexOf(script); + const callRe = /\btl\s*\.\s*(set|to|from|fromTo|add|addLabel|call)\s*\(/g; + let call: RegExpExecArray | null; + while ((call = callRe.exec(script)) !== null) { + const parsed = splitCallArguments(script, call.index + call[0].length - 1); + if (!parsed) continue; + callRe.lastIndex = parsed.endIndex; + const method = call[1]; + const position = parsed.args[TIMELINE_POSITION_ARG_INDEX[method]]; + if (!position) continue; + // `S(id) + 0.2` is the offset form this check exists to promote, and a + // string position ("+=1", "<", a label) is relative to another tween + // rather than to the timeline, so both survive a retime unchanged. + if (/\b[SD]\s*\(/.test(position) || /^["'`]/.test(position)) continue; + const literals = (position.match(/(? value > TIMELINE_POSITION_TOLERANCE_SEC); + if (seconds === undefined) continue; + const owner = scenes.find((scene) => seconds >= scene.start && seconds < scene.start + scene.duration) + || scenes[scenes.length - 1]; + const offset = Math.round((seconds - owner.start) * 1000) / 1000; + found.push({ + method, + seconds, + line: html.slice(0, scriptOffset + call.index).split('\n').length, + suggestion: offset === 0 + ? `S(${JSON.stringify(owner.id)})` + : `S(${JSON.stringify(owner.id)}) + ${offset}`, + scene_id: owner.id, + }); + if (found.length >= TIMELINE_POSITION_MAX_REPORTED) return found; + } + } + return found; +} + export async function referenceFidelityAssetIssues( contract: unknown, compositionDirAbs: string, @@ -1707,6 +1851,31 @@ export async function runContractHtmlQa( prevEnd = Math.max(prevEnd, start + sceneDuration); }); + const sceneWindows = scenes + .map((scene, index) => ({ + id: sceneId(scene) || sceneLabel(scene, index), + start: sceneStartSec(scene), + duration: sceneDurationSec(scene), + })) + .filter((scene) => scene.duration > 0); + const absolutePositions = authoredAbsoluteTimelinePositions(meta.html, sceneWindows); + if (absolutePositions.length) { + const replacements = absolutePositions + .slice(0, 12) + .map((p) => `line ${p.line}: tl.${p.method}(..., ${p.seconds}) -> ${p.suggestion}`) + .join('; '); + issues.push({ + code: 'AUTHORED_ABSOLUTE_TIMELINE_SECONDS', + // Advisory: literal positions are only wrong once windows move; they + // become a real defect the moment a retime shifts data-start. + severity: 'warning', + selector: 'index.html', + message: `${absolutePositions.length} timeline position(s) are absolute seconds. Scene windows move when timing is reconciled (e.g. after narration is measured), and a literal then plays against the wrong scene — ${replacements}.`, + fixHint: 'Position tweens from the scaffold\'s S("") / D("") helpers (they read each section\'s data-start/data-duration), or use relative string positions.', + source: 'orkas-native-contract-html', + }); + } + for (const [index, scene] of scenes.slice(0, 16).entries()) { for (const text of flattenSceneText(scene).slice(0, 5)) { const needle = normalizeForSearch(text); diff --git a/packages/tools/src/render/render.ts b/packages/tools/src/render/render.ts index 5b71d3a..5998854 100644 --- a/packages/tools/src/render/render.ts +++ b/packages/tools/src/render/render.ts @@ -28,6 +28,8 @@ import { isEnvironmentalDraftFailure, recordDraftFailure, recordDraftSuccess, + applyQaFindingWaivers, + qaFindingIsWaivable, runAudioTimingQa, runContractHtmlQa, runSourceAlignmentQa, @@ -88,6 +90,11 @@ export interface DraftParams extends RenderParams { reportPath?: string; findingsPath?: string; frameEvidenceDir?: string; + /** QA finding codes the USER chose to skip after being shown the finding. + * Waivers persist in `/qa/waivers.json`, so later phases report + * the finding as informational and the user is never asked twice. + * Evidence-integrity codes are refused (repaired, not skipped). */ + waive?: string[]; } export type DraftResult = @@ -654,6 +661,44 @@ export async function draft(params: DraftParams): Promise { steps: {}, }; const steps = report.steps as Record; + + // User-granted QA waivers: stored ones persist on the project so the user is + // never asked to skip the same check twice; newly passed codes are recorded. + // Evidence-integrity codes are refused — those are repaired, not skipped. + const waiverPath = join(project, 'qa', 'waivers.json'); + const storedWaivers = await fs.readFile(waiverPath, 'utf8') + .then((text) => JSON.parse(text) as { waived_codes?: unknown }) + .catch(() => null); + const storedCodes = Array.isArray(storedWaivers?.waived_codes) + ? storedWaivers.waived_codes.filter((code): code is string => typeof code === 'string') + : []; + const requestedCodes = (params.waive ?? []).map((code) => code.trim()).filter(Boolean); + const refusedCodes = requestedCodes.filter((code) => !qaFindingIsWaivable(code)); + const waivedCodes = new Set([...storedCodes, ...requestedCodes].filter(qaFindingIsWaivable)); + if (requestedCodes.some((code) => qaFindingIsWaivable(code) && !storedCodes.includes(code))) { + await fs.mkdir(dirname(waiverPath), { recursive: true }); + await fs.writeFile(waiverPath, JSON.stringify({ waived_codes: [...waivedCodes].sort() }, null, 2) + '\n', 'utf8'); + } + if (waivedCodes.size || refusedCodes.length) { + steps.qa_waivers = { + waived_codes: [...waivedCodes].sort(), + ...(refusedCodes.length ? { refused_codes: refusedCodes, refused_reason: 'evidence-integrity findings are repaired, not skipped' } : {}), + }; + } + /** Re-judge one QA step's stored result after user waivers. */ + const waiveQaStep = >(step: T): T => { + const issues = step.issues as Issue[] | undefined; + if (!issues?.length || !waivedCodes.size) return step; + const { issues: next, applied } = applyQaFindingWaivers(issues, waivedCodes); + if (!applied.length) return step; + const errorCount = next.filter((issue) => issue.severity === 'error').length; + const out: Record = { ...step, issues: next, waived_codes: applied }; + if ('error_count' in out) out.error_count = errorCount; + if ('warning_count' in out) out.warning_count = next.filter((issue) => issue.severity === 'warning').length; + if ('ok' in out) out.ok = errorCount === 0; + return out as T; + }; + const repairBudget = await initDraftRepairBudget(project); steps.repair_budget = repairBudget.summary; report.repair_budget = repairBudget.summary; @@ -720,7 +765,10 @@ export async function draft(params: DraftParams): Promise { // A declared contract has to be a usable budget before a render is spent on // it. Repairing the contract is cheap; a draft rendered from a thin one is not. - const designIssues = designContractIssues(contractLoad.value, sceneMapLoad.value, basename(contractLoad.path) || 'composition-manifest.json'); + const designIssues = applyQaFindingWaivers( + designContractIssues(contractLoad.value, sceneMapLoad.value, basename(contractLoad.path) || 'composition-manifest.json'), + waivedCodes, + ).issues; const designErrors = designIssues.filter((issue) => issue.severity === 'error'); steps.design_contract = { ok: designErrors.length === 0, @@ -734,7 +782,7 @@ export async function draft(params: DraftParams): Promise { }, repairBudget); } - const contractHtml = await runContractHtmlQa(loaded.meta, loaded.issues, contractLoad, sceneMapLoad, project); + const contractHtml = waiveQaStep(await runContractHtmlQa(loaded.meta, loaded.issues, contractLoad, sceneMapLoad, project)); steps.contract_html = contractHtml; if (contractHtml.ok === false) { const firstError = ((contractHtml.issues as Issue[] | undefined) || []).find((issue) => issue.severity === 'error'); @@ -744,7 +792,7 @@ export async function draft(params: DraftParams): Promise { }, repairBudget); } - const sourceAlignment = await runSourceAlignmentQa(sceneMapLoad, shotlistLoad); + const sourceAlignment = waiveQaStep(await runSourceAlignmentQa(sceneMapLoad, shotlistLoad)); steps.source_alignment = sourceAlignment; if (sourceAlignment.ok === false) { return failDraft(report, params, 'E_SOURCE_ALIGNMENT_BLOCKED', 'script/shotlist/composition-manifest alignment failed draft QA.', { @@ -753,7 +801,7 @@ export async function draft(params: DraftParams): Promise { }, repairBudget); } - const audioTiming = await runAudioTimingQa(loaded.meta, contractLoad, sceneMapLoad, narrationMapLoad, project); + const audioTiming = waiveQaStep(await runAudioTimingQa(loaded.meta, contractLoad, sceneMapLoad, narrationMapLoad, project)); steps.audio_timing = audioTiming; if (audioTiming.ok === false) { const firstError = ((audioTiming.issues as Issue[] | undefined) || []).find((issue) => issue.severity === 'error'); @@ -819,7 +867,7 @@ export async function draft(params: DraftParams): Promise { steps.media_probe = mediaProbe; if (isRecord(audioNormalize) && 'loudness_after' in audioNormalize) steps.loudness_after = audioNormalize.loudness_after; } - const mediaQa = await buildMediaQa(loaded.meta, mediaProbe); + const mediaQa = waiveQaStep(await buildMediaQa(loaded.meta, mediaProbe)); steps.media_qa = mediaQa; if (mediaQa.ok === false) { return failDraft(report, params, 'E_MEDIA_QA_BLOCKED', 'draft media QA failed.', { @@ -834,7 +882,7 @@ export async function draft(params: DraftParams): Promise { } catch (err) { steps.frame_evidence_error = (err as Error).message; } - const videoQa = summarizeVideoFrameQa(frameEvidence, loaded.meta.durationSec); + const videoQa = waiveQaStep(summarizeVideoFrameQa(frameEvidence, loaded.meta.durationSec)); steps.video_qa = videoQa; if (videoQa.ok === false) { return failDraft(report, params, 'E_VIDEO_QA_BLOCKED', 'video-level QA failed; repair design-contract/scene-map/HTML before Gate D.', { diff --git a/packages/tools/test/composition-scaffold.test.ts b/packages/tools/test/composition-scaffold.test.ts index 23a5a8a..f5e8989 100644 --- a/packages/tools/test/composition-scaffold.test.ts +++ b/packages/tools/test/composition-scaffold.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { prepareComposition, reconcileComposition } from '../src/composition/scaffold.js'; import { resolveHyperframesInvocation } from '../src/hyperframes/client.js'; import { + authoredAbsoluteTimelinePositions, loadCompositionMeta, loadDesignContract, loadNarrationMap, @@ -53,8 +54,42 @@ describe('manifest-owned HyperFrames scaffold', () => { expect(next).toContain('Authored visual survives'); expect(next).toContain('class="authored-scene clip"'); expect(next).toContain('data-duration="12"'); - expect(next).toContain('tl.fromTo("#scene-hook .scene-content", { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: 0.6, ease: "power3.out" }, 0);'); - expect(next).toContain('tl.fromTo("#scene-payoff .scene-content", { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: 0.6, ease: "power3.out" }, 6);'); + // Reveals stay anchored to S(id) — they read the reconciled data-start + // at runtime, so the retime cannot strand them on a literal second. + expect(next).toContain('tl.fromTo("#scene-hook .scene-content", { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: 0.6, ease: "power3.out" }, S("hook"));'); + expect(next).toContain('tl.fromTo("#scene-payoff .scene-content", { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: 0.6, ease: "power3.out" }, S("payoff"));'); + expect(next).toContain('const S = (id)'); + } finally { + rmSync(project, { recursive: true, force: true }); + } + }); + + it('keeps a legacy literal-positioned scaffold literal — S() would reference an undefined helper', async () => { + const project = mkdtempSync(join(tmpdir(), 'ovs-composition-legacy-')); + try { + const manifestPath = join(project, 'composition-manifest.json'); + writeFileSync(manifestPath, JSON.stringify(manifest()), 'utf8'); + // A pre-anchor scaffold: no S()/D() helpers, reveal at a literal second. + writeFileSync(join(project, 'index.html'), [ + '', + '
', + '
', + '
', + '
', + '', + ].join('\n'), 'utf8'); + writeFileSync(manifestPath, JSON.stringify(manifest(12)), 'utf8'); + const reconciled = await reconcileComposition(project); + expect(reconciled).toMatchObject({ ok: true, reconciled: true }); + const next = readFileSync(join(project, 'index.html'), 'utf8'); + expect(next).toContain('data-scene-id="payoff" data-start="6"'); + expect(next).toContain('ease: "power3.out" }, 6);'); + expect(next).not.toContain('S("payoff")'); } finally { rmSync(project, { recursive: true, force: true }); } @@ -75,6 +110,31 @@ describe('manifest-owned HyperFrames scaffold', () => { } }); + it('reports authored absolute timeline seconds with the exact S() replacement', () => { + const scenes = [ + { id: 'hook', start: 0, duration: 5 }, + { id: 'payoff', start: 5, duration: 5 }, + ]; + const html = [ + '', + ].join('\n'); + const found = authoredAbsoluteTimelinePositions(html, scenes); + expect(found).toHaveLength(2); + expect(found[0]).toMatchObject({ method: 'to', seconds: 6.5, scene_id: 'payoff', suggestion: 'S("payoff") + 1.5' }); + // tl.call's position is its THIRD argument, not the second. + expect(found[1]).toMatchObject({ method: 'call', seconds: 5, scene_id: 'payoff', suggestion: 'S("payoff")' }); + }); + + it('has no opinion without scene windows', () => { + expect(authoredAbsoluteTimelinePositions('', [])).toEqual([]); + }); + it('blocks standalone narration until audio is materialized but permits assembler-owned narration', async () => { const project = mkdtempSync(join(tmpdir(), 'ovs-composition-narration-')); try { diff --git a/packages/tools/test/design-contract.test.ts b/packages/tools/test/design-contract.test.ts index c574712..b9d47d5 100644 --- a/packages/tools/test/design-contract.test.ts +++ b/packages/tools/test/design-contract.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from 'vitest'; import { + applyQaFindingWaivers, assertVideoStudioDesignQualityVerdict, compileVideoStudioDesignQualityScorecard, designContractIssues, designContractReadiness, htmlCopySearch, + qaFindingIsWaivable, runSourceAlignmentQa, + type Issue, } from '../src/render/composition-qa'; const FULL = { @@ -258,6 +261,32 @@ describe('runSourceAlignmentQa — legacy shotlist activation', () => { }); }); +describe('QA waivers — the user may skip a look they accept, never the evidence', () => { + it('refuses evidence-integrity and parse-failure codes', () => { + expect(qaFindingIsWaivable('COVER_HEADLINE_NOT_VISIBLE')).toBe(true); + expect(qaFindingIsWaivable('VIDEO_SAMPLE_FRAMES_MISSING')).toBe(false); + expect(qaFindingIsWaivable('SCENE_MAP_REQUIRED_FOR_SOURCE_ALIGNMENT')).toBe(false); + expect(qaFindingIsWaivable('SHOTLIST_PARSE_FAILED')).toBe(false); + }); + + it('downgrades a waived blocking finding to informational and keeps it in the report', () => { + const issues: Issue[] = [ + { code: 'COVER_HEADLINE_NOT_VISIBLE', severity: 'error', message: 'headline missing' }, + { code: 'HTML_MISSING_SCENE_COPY', severity: 'error', message: 'copy missing' }, + { code: 'VIDEO_SAMPLE_FRAMES_MISSING', severity: 'error', message: 'no frames' }, + { code: 'COVER_HERO_NOT_DECLARED', severity: 'warning', message: 'no hero' }, + ]; + const { issues: next, applied } = applyQaFindingWaivers(issues, ['COVER_HEADLINE_NOT_VISIBLE', 'VIDEO_SAMPLE_FRAMES_MISSING']); + expect(applied).toEqual(['COVER_HEADLINE_NOT_VISIBLE']); + expect(next[0]).toMatchObject({ severity: 'info', waived_by_user: true }); + expect(next[0].message).toContain('[skipped by user decision]'); + // Not waived, not waivable, and non-error findings stay untouched. + expect(next[1].severity).toBe('error'); + expect(next[2].severity).toBe('error'); + expect(next[3].severity).toBe('warning'); + }); +}); + describe('design quality scorecard', () => { const passingScores = { content_alignment: 90, diff --git a/packages/tools/test/render-draft.test.ts b/packages/tools/test/render-draft.test.ts index bad281d..a9c27e7 100644 --- a/packages/tools/test/render-draft.test.ts +++ b/packages/tools/test/render-draft.test.ts @@ -152,6 +152,57 @@ describe('design contract preflight', () => { }); }); +describe('user QA waivers', () => { + it('moves past a waived blocking finding, refuses integrity codes, and persists the waiver', async () => { + const p = tmpProject('waived-contract'); + try { + writeHtml(p.composition, '
Launch
'); + // Thin contract (design gate blocks) + unmaterialized narration (the + // NEXT local gate) so the run never needs the HyperFrames backend. + writeFileSync(join(p.composition, 'design-contract.json'), JSON.stringify({ + canvas: { width: 1920, height: 1080, duration: 10 }, + scenes: [{ id: 's1', start: 0, duration: 10, headline: 'Launch' }], + }), 'utf8'); + writeSceneMap(p.composition, { + audio: { owner: 'none', tracks: [] }, + scenes: [{ id: 's1', start: 0, duration: 10, headline: 'Launch', narration_text: 'Narrated opening.' }], + }); + + const blocked = await draft({ project: p.composition, output: p.output, reportPath: p.report }); + expect(blocked).toMatchObject({ ok: false, errorCode: 'E_DESIGN_CONTRACT_BLOCKED' }); + + const waived = await draft({ + project: p.composition, + output: p.output, + reportPath: p.report, + waive: [ + 'DESIGN_CONTRACT_BUDGET_INCOMPLETE', + 'COVER_CONTRACT_INCOMPLETE', + 'SCENE_DEPTH_LAYERS_MISSING', + 'SCENE_MOTION_VERBS_MISSING', + 'VIDEO_SAMPLE_FRAMES_MISSING', + ], + }); + // The design gate no longer blocks; the run fails at the NEXT real gate. + expect(waived.errorCode).toBe('E_AUDIO_TIMING_BLOCKED'); + const waiverStep = (waived.report as { steps: Record }).steps.qa_waivers as Record; + expect(waiverStep.refused_codes).toEqual(['VIDEO_SAMPLE_FRAMES_MISSING']); + expect(waiverStep.waived_codes).toContain('DESIGN_CONTRACT_BUDGET_INCOMPLETE'); + + // Persisted: the integrity code was refused, the rest survive the param. + const persisted = JSON.parse(readFileSync(join(p.composition, 'qa', 'waivers.json'), 'utf8')) as { waived_codes: string[] }; + expect(persisted.waived_codes).toContain('SCENE_DEPTH_LAYERS_MISSING'); + expect(persisted.waived_codes).not.toContain('VIDEO_SAMPLE_FRAMES_MISSING'); + + // A later run without the parameter is never re-blocked on a waived code. + const again = await draft({ project: p.composition, output: p.output, reportPath: p.report }); + expect(again.errorCode).toBe('E_AUDIO_TIMING_BLOCKED'); + } finally { + rmSync(p.root, { recursive: true, force: true }); + } + }); +}); + describe('composition draft gate', () => { it('blocks draft from current narration facts before invoking HyperFrames', async () => { const p = tmpProject('draft-narration-facts');