Skip to content

Commit ec3aeff

Browse files
authored
Merge pull request #17 from Orkas-AI/sync/scaffold-scene-anchors
Anchor tweens to scene windows; name every literal second's replacement
2 parents 052e227 + 4aeecce commit ec3aeff

15 files changed

Lines changed: 531 additions & 34 deletions

File tree

packages/cli/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ const draft = defineCommand({
8383
report: { type: 'string', description: 'write the full draft QA report to this JSON path' },
8484
findings: { type: 'string', description: 'write check findings JSON to this path' },
8585
'evidence-dir': { type: 'string', description: 'directory for sampled frame evidence/contact sheet' },
86+
waive: { type: 'string', description: 'comma-separated QA finding codes the user chose to skip (persisted in qa/waivers.json; evidence-integrity codes are refused)' },
8687
},
8788
async run({ args }) {
8889
const r = await renderTool.draft({
@@ -92,6 +93,7 @@ const draft = defineCommand({
9293
reportPath: args.report ? String(args.report) : undefined,
9394
findingsPath: args.findings ? String(args.findings) : undefined,
9495
frameEvidenceDir: args['evidence-dir'] ? String(args['evidence-dir']) : undefined,
96+
waive: args.waive ? String(args.waive).split(',').map((code) => code.trim()).filter(Boolean) : undefined,
9597
onProgress: (c) => process.stderr.write(c),
9698
});
9799
printJson(r);
@@ -507,6 +509,7 @@ const gate = defineCommand({
507509
gate: { type: 'string', default: 'none', description: 'gate_a | gate_b | gate_c | preview | gate_d' },
508510
decision: { type: 'string', default: 'none', description: 'approve | revise | none' },
509511
scope: { type: 'string', default: 'unknown', description: 'visual_only | gate_b_payload | none | unknown' },
512+
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' },
510513
recovery: { type: 'string', default: 'unknown', description: 'available | not_available | unknown' },
511514
'recovery-decision': { type: 'string', default: 'none', description: 'legacy input only: new_visual_revision | pause | none; never emit a new recovery form' },
512515
'artifact-state': { type: 'string', default: 'unknown', description: 'new | unchanged | changed | unknown' },
@@ -520,6 +523,7 @@ const gate = defineCommand({
520523
gate: String(args.gate) as GateTransitionInput['gate'],
521524
decision: String(args.decision) as GateTransitionInput['decision'],
522525
scope: String(args.scope) as GateTransitionInput['scope'],
526+
origin: String(args.origin) as GateTransitionInput['origin'],
523527
recovery: String(args.recovery) as GateTransitionInput['recovery'],
524528
recoveryDecision: String(args['recovery-decision']) as GateTransitionInput['recoveryDecision'],
525529
artifactState: String(args['artifact-state']) as GateTransitionInput['artifactState'],

packages/core/src/gates/transition.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,20 @@ export type RecoveryState = 'unknown' | 'available' | 'not_available';
77
export type RecoveryDecision = 'none' | 'new_visual_revision' | 'pause';
88
export type ArtifactState = 'unknown' | 'new' | 'unchanged' | 'changed';
99
export type ApprovalStatus = 'unknown' | 'none' | 'pending' | 'approved';
10+
/** Who asked for the change decides whether to ask again. `user` means the
11+
* CURRENT turn names the change in the user's own words; a model-initiated
12+
* change — or a reply that mixes an instruction with the model's own
13+
* proposal — is `model` (the higher bar wins). No host verifies this in OVS:
14+
* it is the driving agent's honest self-report. */
15+
export type ChangeOrigin = 'unknown' | 'user' | 'model';
1016

1117
export interface GateTransitionInput {
1218
line?: VideoLine;
1319
artifact?: GateArtifact;
1420
gate?: GateName;
1521
decision?: GateDecision;
1622
scope?: RevisionScope;
23+
origin?: ChangeOrigin;
1724
recovery?: RecoveryState;
1825
recoveryDecision?: RecoveryDecision;
1926
artifactState?: ArtifactState;
@@ -37,6 +44,7 @@ const VALID = {
3744
gate: new Set<GateName>(['none', 'gate_a', 'gate_b', 'gate_c', 'preview', 'gate_d']),
3845
decision: new Set<GateDecision>(['none', 'approve', 'revise']),
3946
scope: new Set<RevisionScope>(['unknown', 'none', 'visual_only', 'gate_b_payload']),
47+
origin: new Set<ChangeOrigin>(['unknown', 'user', 'model']),
4048
recovery: new Set<RecoveryState>(['unknown', 'available', 'not_available']),
4149
recoveryDecision: new Set<RecoveryDecision>(['none', 'new_visual_revision', 'pause']),
4250
artifactState: new Set<ArtifactState>(['unknown', 'new', 'unchanged', 'changed']),
@@ -95,6 +103,7 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi
95103
gate: raw.gate ?? 'none',
96104
decision: raw.decision ?? 'none',
97105
scope: raw.scope ?? 'unknown',
106+
origin: raw.origin ?? 'unknown',
98107
recovery: raw.recovery ?? 'unknown',
99108
recoveryDecision: raw.recoveryDecision ?? 'none',
100109
artifactState: raw.artifactState ?? 'unknown',
@@ -106,6 +115,7 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi
106115
assertEnum('gate', input.gate, VALID.gate);
107116
assertEnum('decision', input.decision, VALID.decision);
108117
assertEnum('scope', input.scope, VALID.scope);
118+
assertEnum('origin', input.origin, VALID.origin);
109119
assertEnum('recovery', input.recovery, VALID.recovery);
110120
assertEnum('recoveryDecision', input.recoveryDecision, VALID.recoveryDecision);
111121
assertEnum('artifactState', input.artifactState, VALID.artifactState);
@@ -115,6 +125,21 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi
115125
}
116126
const lineOps = lineOperations(input.line, input.artifact);
117127

128+
// Who asked for the change decides whether to ask again: a change the
129+
// current user turn dictates is applied directly — the instruction is itself
130+
// the authorization, and asking them to confirm a change they just asked for
131+
// costs a full round trip and teaches them their instructions are not taken
132+
// at face value. A mixed reply (instruction + model proposal) is `model`.
133+
if (input.decision === 'revise' && input.scope === 'gate_b_payload' && input.origin === 'user') {
134+
return result({
135+
nextAction: 'apply_user_instruction_then_approve_plan',
136+
authorities: ['edit_current_artifact', 'approve_gate_b'],
137+
allowedOps: ['edit_current_artifact', 'continue_approved_plan'],
138+
prohibitedOps: NO_VISUAL_RESET,
139+
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.',
140+
});
141+
}
142+
118143
// A signed-payload amendment creates a new signature and therefore a fresh
119144
// OVS draft-repair cycle. Recovery evidence for the old signature is stale.
120145
if (input.decision === 'revise' && input.scope === 'gate_b_payload') {
@@ -165,9 +190,9 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi
165190
});
166191
}
167192
return result({
168-
nextAction: 'report_visual_qa_blocker',
169-
prohibitedOps: ['emit_form', 'edit_files', ...NO_VISUAL_RESET],
170-
reason: 'Technical QA exhaustion never creates a user authorization form. Wait for a real revision request, which authorizes the next bounded cycle.',
193+
nextAction: 'present_findings_and_ask_user_direction',
194+
prohibitedOps: ['emit_form', 'edit_files', 'restart_visual_qa_cycle', ...NO_VISUAL_RESET],
195+
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.',
171196
});
172197
}
173198

@@ -263,7 +288,7 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi
263288
authorities: ['edit_current_artifact', 'restart_visual_qa_cycle'],
264289
allowedOps: lineOps.edit,
265290
prohibitedOps: ['emit_form', ...NO_VISUAL_RESET],
266-
reason: 'Consume the legacy recovery submission once. New turns use the original revise decision and OVS content-signature reset.',
291+
reason: 'Consume the legacy recovery submission once — by making a materially different edit, never by repeating a strategy the recorded evidence already shows failed.',
267292
});
268293
}
269294
if (input.recovery === 'unknown') {
@@ -285,9 +310,9 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi
285310

286311
if (input.recovery === 'available') {
287312
return result({
288-
nextAction: 'report_visual_qa_blocker',
289-
prohibitedOps: ['emit_form', 'edit_files', ...NO_VISUAL_RESET],
290-
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.',
313+
nextAction: 'present_findings_and_ask_user_direction',
314+
prohibitedOps: ['emit_form', 'edit_files', 'restart_visual_qa_cycle', ...NO_VISUAL_RESET],
315+
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.',
291316
});
292317
}
293318

packages/core/test/gate-transition.test.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,56 @@ describe('resolveGateTransition', () => {
7373
expect(result.prohibited_ops).toContain('emit_form');
7474
});
7575

76-
it('reports an exhausted QA blocker without creating a recovery form', () => {
76+
it('turns an exhausted QA cycle into a user fork, not a silent wait', () => {
7777
const result = resolveGateTransition({
7878
line: 'compose',
7979
artifact: 'composition',
8080
recovery: 'available',
8181
errorCode: 'E_VISUAL_REVISION_EXPLICIT_AUTHORIZATION_REQUIRED',
8282
});
83-
expect(result).toMatchObject({ next_action: 'report_visual_qa_blocker', form: null });
83+
expect(result).toMatchObject({ next_action: 'present_findings_and_ask_user_direction', form: null });
8484
expect(result.prohibited_ops).toContain('emit_form');
85+
expect(result.prohibited_ops).toContain('edit_files');
86+
expect(result.prohibited_ops).toContain('restart_visual_qa_cycle');
87+
expect(result.reason).toMatch(/materially different edit/i);
88+
});
89+
90+
it('offers the fork on bare exhausted recovery with no decision at all', () => {
91+
const result = resolveGateTransition({
92+
line: 'compose',
93+
artifact: 'composition',
94+
recovery: 'available',
95+
});
96+
expect(result).toMatchObject({ next_action: 'present_findings_and_ask_user_direction', form: null });
97+
expect(result.reason).toMatch(/waive|skipping/i);
98+
});
99+
100+
it('applies a user-dictated amendment directly instead of re-confirming it', () => {
101+
const result = resolveGateTransition({
102+
line: 'compose',
103+
artifact: 'composition',
104+
gate: 'gate_b',
105+
decision: 'revise',
106+
scope: 'gate_b_payload',
107+
origin: 'user',
108+
});
109+
expect(result).toMatchObject({ next_action: 'apply_user_instruction_then_approve_plan', form: null });
110+
expect(result.authorities).toContain('approve_gate_b');
111+
expect(result.reason).toMatch(/own words/i);
112+
});
113+
114+
it('keeps the Gate B amendment for model-initiated or mixed changes', () => {
115+
for (const origin of ['model', 'unknown'] as const) {
116+
const result = resolveGateTransition({
117+
line: 'compose',
118+
artifact: 'composition',
119+
gate: 'gate_b',
120+
decision: 'revise',
121+
scope: 'gate_b_payload',
122+
origin,
123+
});
124+
expect(result.next_action).toBe('open_gate_b_amendment');
125+
}
85126
});
86127

87128
it('rejects mixed current and legacy decision fields', () => {

packages/mcp/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,17 @@ server.tool(
6666
report_path: z.string().optional(),
6767
findings_path: z.string().optional(),
6868
frame_evidence_dir: z.string().optional(),
69+
waive: z.array(z.string()).optional().describe('QA finding codes the user chose to skip; persisted so later phases never re-block on them'),
6970
},
70-
({ project, out, quality, report_path, findings_path, frame_evidence_dir }) =>
71+
({ project, out, quality, report_path, findings_path, frame_evidence_dir, waive }) =>
7172
format(renderTool.draft({
7273
project,
7374
output: out,
7475
quality,
7576
reportPath: report_path,
7677
findingsPath: findings_path,
7778
frameEvidenceDir: frame_evidence_dir,
79+
waive,
7880
onProgress: toStderr,
7981
})),
8082
);
@@ -212,6 +214,7 @@ server.tool(
212214
gate: z.enum(['none', 'gate_a', 'gate_b', 'gate_c', 'preview', 'gate_d']).optional(),
213215
decision: z.enum(['none', 'approve', 'revise']).optional(),
214216
scope: z.enum(['unknown', 'none', 'visual_only', 'gate_b_payload']).optional(),
217+
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'),
215218
recovery: z.enum(['unknown', 'available', 'not_available']).optional(),
216219
recoveryDecision: z.enum(['none', 'new_visual_revision', 'pause']).optional(),
217220
artifactState: z.enum(['unknown', 'new', 'unchanged', 'changed']).optional(),

0 commit comments

Comments
 (0)