Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
Expand Down Expand Up @@ -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' },
Expand All @@ -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'],
Expand Down
39 changes: 32 additions & 7 deletions packages/core/src/gates/transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@ 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;
artifact?: GateArtifact;
gate?: GateName;
decision?: GateDecision;
scope?: RevisionScope;
origin?: ChangeOrigin;
recovery?: RecoveryState;
recoveryDecision?: RecoveryDecision;
artifactState?: ArtifactState;
Expand All @@ -37,6 +44,7 @@ const VALID = {
gate: new Set<GateName>(['none', 'gate_a', 'gate_b', 'gate_c', 'preview', 'gate_d']),
decision: new Set<GateDecision>(['none', 'approve', 'revise']),
scope: new Set<RevisionScope>(['unknown', 'none', 'visual_only', 'gate_b_payload']),
origin: new Set<ChangeOrigin>(['unknown', 'user', 'model']),
recovery: new Set<RecoveryState>(['unknown', 'available', 'not_available']),
recoveryDecision: new Set<RecoveryDecision>(['none', 'new_visual_revision', 'pause']),
artifactState: new Set<ArtifactState>(['unknown', 'new', 'unchanged', 'changed']),
Expand Down Expand Up @@ -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',
Expand All @@ -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);
Expand All @@ -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') {
Expand Down Expand Up @@ -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.',
});
}

Expand Down Expand Up @@ -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') {
Expand All @@ -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 <code>`), and end the turn. The user\'s reply grants the next cycle; never restart one as the silent default.',
});
}

Expand Down
45 changes: 43 additions & 2 deletions packages/core/test/gate-transition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,17 @@ 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,
quality,
reportPath: report_path,
findingsPath: findings_path,
frameEvidenceDir: frame_evidence_dir,
waive,
onProgress: toStderr,
})),
);
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading