@rig-c/review 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -205,6 +205,31 @@ printed: every current instance binds only `127.0.0.1`.
205
205
  The printed feedback retrieval command uses POSIX shell quoting for literal path
206
206
  arguments. It is not a PowerShell or cmd.exe command string.
207
207
 
208
+ ### Launcher contract
209
+
210
+ A launcher may start `rig-review open` as a child process and rely on its stdout,
211
+ stderr and exit code alone:
212
+
213
+ 1. On success stdout is exactly one line, the URL. `open` never writes anything
214
+ else to stdout.
215
+ 2. On success stderr carries the human lines in this fixed order: the animation
216
+ diagnostic (when a clip is selected), the control notice (with `--control`),
217
+ then the copyable retrieval line `Review started|reused; feedback: rig-review feedback …`.
218
+ 3. A refusal exits with code 1, writes nothing to stdout, and its first stderr line
219
+ starts with a name, followed by a colon and the explanation:
220
+ - `OPEN_INPUT_MISSING <path>`: the input or `--compare` path does not exist, or is neither a directory nor a regular file.
221
+ - `OPEN_INPUT_UNRECOGNIZED <path>`: the folder holds no recognized model, skeleton, motion, report, reference or pose sequence, or the file is not a JSON manifest.
222
+ - `OPEN_REFERENCE_REQUIRED`: `--compare` was given without an explicit `--reference`.
223
+ - `OPEN_ANIMATION_UNKNOWN <name>`: the explicit or report-named clip (JSON-quoted) is not in the compiled model.
224
+ - `OPEN_ANIMATION_AMBIGUOUS`: no flag or report selects one clip, or the A/B reports conflict.
225
+ - `OPEN_ANIMATION_UNVALIDATABLE`: a clip was requested but no compiled model can validate it.
226
+ - `OPEN_OPTION_INVALID <flag>`: an unknown, duplicate, empty or value-less option.
227
+ 4. Exit codes: 0 applied, 1 refused. (`control` keeps its own exit 2 for an
228
+ unapplied command.)
229
+
230
+ Package validation and worker startup refusals keep their existing messages; the
231
+ named refusals above are decided before any state is written.
232
+
208
233
  ### Managed front and write capability
209
234
 
210
235
  `open` is **managed by default**. It ensures one detached loopback front at
@@ -230,7 +255,10 @@ One origin cannot isolate scripts that run on it, so writes are bound to a
230
255
  per-instance capability rather than to the page's origin or the key in its path.
231
256
  Each worker mints a 256-bit capability kept only in its private 0600 open record.
232
257
  `open` (managed or direct) and the foreground `review`/`review compare` commands
233
- print it in the `#cap=` fragment, which browsers never send to a server. The page
258
+ print it in the `#cap=` fragment, which browsers never send to a server. That
259
+ printed link goes only to the terminal of the command that started the instance.
260
+ Output meant for a log does not carry it: the install smoke (`bun run smoke`, whose output CI publishes) prints the same
261
+ URL as `#cap=<redacted>` and fails if any capability value reaches its stdout. The page
234
262
  moves it into memory and that tab's `sessionStorage`, then removes the fragment
235
263
  from the address bar. Every mutation sends it as `X-Review-Capability`; the front
236
264
  checks it before forwarding and the worker compares it again in constant time
@@ -239,9 +239,8 @@ A strip plan with one clock basis reports `plan.basis:"single"|"a"|"b"`. A compa
239
239
 
240
240
  `rig-review open <input> [--reference <local-video>] [--compare <input>]
241
241
  [--animation <name>]` hashes and validates the input, starts/reuses a fixed-binding
242
- background instance and prints a URL alone on stdout. Its matching read-only
243
- `feedback <input> --reference <same-video> [--compare <input> --animation <name>]`
244
- command is printed on stderr. Existing `feedback compare ...` remains supported.
242
+ background instance and follows the [launcher contract](../README.md#launcher-contract)
243
+ for stdout, stderr and exit codes. Existing `feedback compare ...` remains supported.
245
244
  Open animation selection uses `--animation` first, a normalized report `animation`
246
245
  second, and a unique actual model clip (or common A/B clip) last. Report metadata
247
246
  comes from the exact already-hashed recognized report bytes, never a separately
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/review",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Local human rig inspection and structured feedback export",
6
6
  "license": "MIT",
@@ -2,21 +2,25 @@
2
2
  import type {FeedbackCatalog} from './feedback-validation.ts';
3
3
  import type {ReportSeeds} from './report.ts';
4
4
  export interface AnimationSelection {name:string;source:'cli'|'report'|'unique';reportPaths?:string[]}
5
+ /** The message stays the shared check/feedback text; `kind` lets the open launcher name the refusal. */
6
+ export class AnimationSelectionError extends Error {
7
+ constructor(message:string,readonly kind:'empty'|'unknown'|'ambiguous'|'unvalidatable',readonly label:string|null=null,readonly animation?:string){super(message);}
8
+ }
5
9
  interface Source {label:string;catalog:Pick<FeedbackCatalog,'animations'>;report:Pick<ReportSeeds,'animation'|'path'>|null}
6
10
  export function selectAnimation(sources:Source[],explicit?:string,allowStatic=false):AnimationSelection|null {
7
11
  if(!sources.length||sources.length>2)throw new Error('open.animation: expected one or two actual catalogs');
8
- if(explicit!==undefined&&(!explicit.trim()))throw new Error('open.animation: expected nonempty animation name');
9
- const requireName=(name:string)=>{for(const source of sources){if(source.catalog.animations===null)throw new Error(`open.${source.label}.animation: validation unavailable without compiled model`);if(!source.catalog.animations.some(clip=>clip.name===name))throw new Error(`open.${source.label}.animation: model has no animation ${JSON.stringify(name)}`);}};
12
+ if(explicit!==undefined&&(!explicit.trim()))throw new AnimationSelectionError('open.animation: expected nonempty animation name','empty');
13
+ const requireName=(name:string)=>{for(const source of sources){if(source.catalog.animations===null)throw new AnimationSelectionError(`open.${source.label}.animation: validation unavailable without compiled model`,'unvalidatable',source.label);if(!source.catalog.animations.some(clip=>clip.name===name))throw new AnimationSelectionError(`open.${source.label}.animation: model has no animation ${JSON.stringify(name)}`,'unknown',source.label,name);}};
10
14
  if(explicit!==undefined){requireName(explicit);return {name:explicit,source:'cli'};}
11
15
  if(allowStatic&&sources.length===1&&sources[0]!.catalog.animations===null)return null;
12
16
  const reports=sources.filter(source=>source.report?.animation!==undefined);
13
17
  const names=[...new Set(reports.map(source=>source.report!.animation!))];
14
- if(names.length>1)throw new Error(`open.animation: conflicting report animations: ${reports.map(source=>`${source.label}=${JSON.stringify(source.report!.animation)}`).join(', ')}; select --animation explicitly`);
18
+ if(names.length>1)throw new AnimationSelectionError(`open.animation: conflicting report animations: ${reports.map(source=>`${source.label}=${JSON.stringify(source.report!.animation)}`).join(', ')}; select --animation explicitly`,'ambiguous');
15
19
  if(names.length===1){requireName(names[0]!);return {name:names[0]!,source:'report',reportPaths:[...new Set(reports.map(source=>source.report!.path))]};}
16
- if(sources.some(source=>source.catalog.animations===null))throw new Error('open.animation: validation unavailable without compiled models');
20
+ if(sources.some(source=>source.catalog.animations===null))throw new AnimationSelectionError('open.animation: validation unavailable without compiled models','unvalidatable');
17
21
  const common=sources[0]!.catalog.animations!.filter(clip=>sources.every(source=>source.catalog.animations!.some(other=>other.name===clip.name))).map(clip=>clip.name);
18
22
  if(allowStatic&&sources.length===1&&sources[0]!.catalog.animations!.length===0)return null;
19
- if(common.length!==1)throw new Error(`open.animation: expected --animation; ${sources.length>1?'common animations':'model animations'}: ${common.join(', ')||'(none)'}`);
23
+ if(common.length!==1)throw new AnimationSelectionError(`open.animation: expected --animation; ${sources.length>1?'common animations':'model animations'}: ${common.join(', ')||'(none)'}`,'ambiguous');
20
24
  return {name:common[0]!,source:'unique'};
21
25
  }
22
26
  export function animationDiagnostic(selection:AnimationSelection):string {
package/src/cli.ts CHANGED
@@ -35,7 +35,7 @@ async function stripCommand(args:string[],options:{stateDir?:string}){
35
35
  }
36
36
  export function parseOpenInputs(args:string[]):OpenInputs {
37
37
  const input=args.shift();if(!input||input.startsWith('--'))throw new Error(usage);const result:OpenInputs={input},seen=new Set<string>();
38
- while(args.length){const flag=args.shift()!;if(seen.has(flag))throw new Error(`Duplicate open option ${flag}`);seen.add(flag);if(flag==='--control'){result.control=true;continue;}if(flag==='--direct'){result.direct=true;continue;}const value=args.shift();if(value===undefined||value==='')throw new Error(`Missing value for ${flag}`);if(flag==='--reference')result.reference=value;else if(flag==='--compare')result.compare=value;else if(flag==='--animation')result.animation=value;else throw new Error(`Unknown open option ${flag}`);}return result;
38
+ while(args.length){const flag=args.shift()!;if(seen.has(flag))throw new Error(`OPEN_OPTION_INVALID ${flag}: Duplicate open option ${flag}`);seen.add(flag);if(flag==='--control'){result.control=true;continue;}if(flag==='--direct'){result.direct=true;continue;}const value=args.shift();if(value===undefined||value==='')throw new Error(`OPEN_OPTION_INVALID ${flag}: Missing value for ${flag}`);if(flag==='--reference')result.reference=value;else if(flag==='--compare')result.compare=value;else if(flag==='--animation')result.animation=value;else throw new Error(`OPEN_OPTION_INVALID ${flag}: Unknown open option ${flag}`);}return result;
39
39
  }
40
40
  async function controlCommand(args:string[],stateDir?:string){
41
41
  const input=args.shift();if(!input||input==='--help'||input==='-h'){console.log(usage);return;}
@@ -83,7 +83,8 @@ function commandHelpRequested(args:readonly string[],command:'open'|'review'|'ch
83
83
  export async function main(args:string[],options:{stateDir?:string}={}){
84
84
  options={stateDir:options.stateDir??defaultStateDir()};if(args[0]==='--')args.shift();
85
85
  if(args[0]==='control')return controlCommand(args.slice(1),options.stateDir);
86
- if(args[0]==='open'){const openArgs=args.slice(1);if(commandHelpRequested(openArgs,'open')){console.log(usage);return;}const inputs=parseOpenInputs(openArgs);const result=await openReview(inputs,options);console.log(result.url);if(inputs.control)console.error('Inspection control enabled; query the current connected page with rig-review control and the same inputs.');if(result.selection)console.error(animationDiagnostic(result.selection));console.error(`Review ${result.reused?'reused':'started'}; feedback: rig-review feedback ${[result.inputs.input,...(result.inputs.compare?['--compare',result.inputs.compare]:[]),...(result.inputs.reference?['--reference',result.inputs.reference]:[]),...(result.inputs.animation?['--animation',result.inputs.animation]:[])].map(shellWord).join(' ')}`);return;}
86
+ if(args[0]==='open'){const openArgs=args.slice(1);if(commandHelpRequested(openArgs,'open')){console.log(usage);return;}const inputs=parseOpenInputs(openArgs);const result=await openReview(inputs,options);// Launcher contract (README): stdout is the URL line alone; stderr is diagnostic, control notice, retrieval, in that order.
87
+ console.log(result.url);if(result.selection)console.error(animationDiagnostic(result.selection));if(inputs.control)console.error('Inspection control enabled; query the current connected page with rig-review control and the same inputs.');console.error(`Review ${result.reused?'reused':'started'}; feedback: rig-review feedback ${[result.inputs.input,...(result.inputs.compare?['--compare',result.inputs.compare]:[]),...(result.inputs.reference?['--reference',result.inputs.reference]:[]),...(result.inputs.animation?['--animation',result.inputs.animation]:[])].map(shellWord).join(' ')}`);return;}
87
88
  if(args[0]==='strip'){args.shift();if(args.includes('--help')){console.log(usage+'\nStrip: strip <package> --reference <declared-video-id> --animation <name> (--frames FIRST:LAST | --all-feedback) [--json] [--out path]\n strip compare <a> <b> --reference <video> --animation <name> --basis a|b (--frames FIRST:LAST | --all-feedback) [--json] [--out path]');return;}return stripCommand(args,options);}let command:'review'|'check'|'feedback'='review';if(args[0]==='check'||args[0]==='feedback'){command=args.shift() as 'check'|'feedback';}
88
89
  if(args[0]==='--help'||args[0]==='-h'){console.log(usage);return;}
89
90
  let format:'json'|'issue'='json';if(command==='feedback'){const parsed=feedbackFormat(args);args=parsed.args;format=parsed.format;}
package/src/manifest.ts CHANGED
@@ -103,6 +103,10 @@ export async function loadReview(input: string): Promise<ReviewPackage> {
103
103
  return {root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog};
104
104
  }
105
105
 
106
+ /** Same text for every command; `root` lets the open launcher name the refused input. */
107
+ export class UnrecognizedOutputError extends Error {
108
+ constructor(readonly root:string){super('Output folder: no recognized skeleton.*, build/skeleton.model.json, motion.json, report.json, source video, pose sequence or contact strip');}
109
+ }
106
110
  /** An adapter over an existing output directory; never writes a manifest there. */
107
111
  async function folderManifest(root:string):Promise<ReviewManifest>{
108
112
  const entries=(await readdir(root,{withFileTypes:true})).filter(v=>v.isFile()||v.isSymbolicLink()).map(v=>v.name).sort();
@@ -121,7 +125,7 @@ async function folderManifest(root:string):Promise<ReviewManifest>{
121
125
  }
122
126
  const name=basename(root);const packageId=name.replace(/[^a-zA-Z0-9._-]+/g,'-').replace(/^[^a-zA-Z0-9]+/,'').slice(0,128)||'output-bundle';
123
127
  const authoring=entries.filter(n=>n==='motion.json'||n==='report.json'||(/^skeleton\./i.test(n)&&n!=='skeleton.model.json')).map(path=>({label:path+' · read only',path}));
124
- if(!references.length&&!authoring.length&&!model)throw new Error('Output folder: no recognized skeleton.*, build/skeleton.model.json, motion.json, report.json, source video, pose sequence or contact strip');
128
+ if(!references.length&&!authoring.length&&!model)throw new UnrecognizedOutputError(root);
125
129
  return parseManifest({schema:'rig-review/1',id:packageId,title:name,revision:'folder-adapter/1',model,motion:has('motion.json')?'motion.json':undefined,references,authoring,checkpoints:references.map(r=>({id:r.id,title:'Inspect '+r.label,reference:r.id,workspace:'motion'}))});
126
130
  }
127
131
 
package/src/open.ts CHANGED
@@ -3,16 +3,16 @@ import {parseControlCommand,type ControlCommand,type ControlReceipt} from './con
3
3
  import {createHash,randomBytes,randomUUID} from 'node:crypto';
4
4
  import {spawn} from 'node:child_process';
5
5
  import {homedir} from 'node:os';
6
- import {mkdir,readFile,readdir,writeFile,rename,unlink,rm,rmdir,realpath,lstat,open as openFile} from 'node:fs/promises';
6
+ import {mkdir,readFile,readdir,writeFile,rename,unlink,rm,rmdir,realpath,lstat,stat,open as openFile} from 'node:fs/promises';
7
7
  import {constants} from 'node:fs';
8
8
  import {basename,dirname,join,relative,resolve,isAbsolute} from 'node:path';
9
9
  import {fileURLToPath} from 'node:url';
10
10
  import {loadReferencedReview,selectedReference,assertReviewUnchanged} from './reference.ts';
11
- import {loadReview,type ReviewPackage} from './manifest.ts';
11
+ import {loadReview,UnrecognizedOutputError,type ReviewPackage} from './manifest.ts';
12
12
  import {loadCompare,assertCompareUnchanged,type ComparePackage} from './compare.ts';
13
13
  import {startServer} from './server.ts';
14
14
  import {startCompareServer} from './compare-server.ts';
15
- import {selectAnimation,type AnimationSelection} from './animation-selection.ts';
15
+ import {selectAnimation,AnimationSelectionError,type AnimationSelection} from './animation-selection.ts';
16
16
  import {capabilityHeader,capabilityPattern,type OpenIdentity} from './open-protocol.ts';
17
17
  import {ensureFront,frontPort} from './front.ts';
18
18
  import {playPackagePath} from './versions.ts';
@@ -74,11 +74,28 @@ export async function acquireClaim<T>(control:string,name:string,nonce:string,re
74
74
  catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error;}if(performance.now()>deadline)throw new Error(busy);await sleep(30);}}}
75
75
  catch(error){await rm(candidate,{recursive:true,force:true});throw error;}
76
76
  }
77
+ /** Launcher contract (README "Launcher contract"): an `open` refusal's first line starts with a stable OPEN_* name.
78
+ * The shared loaders keep their check/feedback text; only this path renames them. */
79
+ async function assertOpenInput(path:string):Promise<void>{
80
+ let info;try{info=await stat(path);}catch(e){if(['ENOENT','ENOTDIR','ELOOP','ENAMETOOLONG'].includes((e as NodeJS.ErrnoException).code??''))throw new Error(`OPEN_INPUT_MISSING ${path}: no such path; expected an existing output directory or review.json`);throw e;}
81
+ if(!info.isDirectory()&&!info.isFile())throw new Error(`OPEN_INPUT_MISSING ${path}: found a non-regular file; expected an output directory or a review.json manifest`);
82
+ if(info.isFile()){try{JSON.parse(await readFile(path,'utf8'));}catch(e){throw new Error(`OPEN_INPUT_UNRECOGNIZED ${path}: not a JSON manifest; expected an output directory or a review.json manifest`,{cause:e});}}
83
+ }
84
+ async function openRefusal(error:unknown,inputs:OpenInputs):Promise<unknown>{
85
+ if(error instanceof UnrecognizedOutputError){const first=await realpath(resolve(inputs.input)).catch(()=>null),path=inputs.compare!==undefined&&error.root!==first?inputs.compare:inputs.input;return new Error(`OPEN_INPUT_UNRECOGNIZED ${path}: ${error.message}`,{cause:error});}
86
+ if(!(error instanceof AnimationSelectionError))return error;
87
+ const detail=(error.label==='a'||error.label==='b'?`side ${error.label}: `:'')+error.message.replace(/^open\.(?:[a-z]+\.)?animation: /,'');
88
+ const name=error.kind==='empty'?'OPEN_OPTION_INVALID --animation':error.kind==='unknown'?`OPEN_ANIMATION_UNKNOWN ${JSON.stringify(error.animation)}`:error.kind==='ambiguous'?'OPEN_ANIMATION_AMBIGUOUS':'OPEN_ANIMATION_UNVALIDATABLE';
89
+ return new Error(`${name}: ${detail}`,{cause:error});
90
+ }
77
91
  /** Managed is the default: the printed URL goes through the one loopback front (127.0.0.1:4318, or
78
92
  * RIG_REVIEW_FRONT_PORT) so browser storage keeps one origin across reopens. `direct` keeps the worker's own port. */
79
93
  export async function openReview(inputs:OpenInputs,options:{stateDir?:string;idleMs?:number;direct?:boolean;frontPort?:number;frontIdleMs?:number}={}):Promise<{url:string;reused:boolean;nonce:string;inputs:OpenInputs;selection:AnimationSelection|null;direct:boolean}>{
80
94
  const direct=options.direct??inputs.direct??false,port=direct?0:frontPort(options.frontPort);
81
- const p=await loadOpenPackage(inputs),fingerprint=await openFingerprint(),key=openKey(p,fingerprint);
95
+ for(const path of [inputs.input,...(inputs.compare!==undefined?[inputs.compare]:[])])await assertOpenInput(path);
96
+ if(inputs.compare!==undefined&&!inputs.reference)throw new Error('OPEN_REFERENCE_REQUIRED: compare: explicit --reference required');
97
+ let p:OpenPackage;try{p=await loadOpenPackage(inputs);}catch(error){throw await openRefusal(error,inputs);}
98
+ const fingerprint=await openFingerprint(),key=openKey(p,fingerprint);
82
99
  const stateDir=await canonicalDestination(resolve(options.stateDir??defaultStateDir())),roots=p.mode==='single'?[p.p.root]:[p.p.a.root,p.p.b.root];
83
100
  for(const root of roots){const rel=relative(root,stateDir);if(!rel||rel!=='..'&&!rel.startsWith('..'+(process.platform==='win32'?'\\':'/'))&&!isAbsolute(rel))throw new Error('open.state: must be outside input packages');}
84
101
  const control=join(stateDir,'open');await mkdir(control,{recursive:true,mode:0o700});const canonicalControl=await realpath(control);if((await lstat(control)).isSymbolicLink())throw new Error('open.state: control directory cannot be a symlink');