spine-rigc 0.6.0 → 0.7.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/cli.ts CHANGED
@@ -31,8 +31,23 @@
31
31
  * Its paths resolve against the cuts.json file itself, so the table travels
32
32
  * with the project that owns the art rather than with this repository.
33
33
  */
34
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
34
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
35
35
  import { basename, dirname, join, resolve } from 'node:path';
36
+ import {
37
+ BallotError,
38
+ buildBallot,
39
+ ledgerLineText,
40
+ MAX_CANDIDATES,
41
+ MIN_CANDIDATES,
42
+ parseLedger,
43
+ readBallotManifest,
44
+ resultFilename,
45
+ TIE,
46
+ verifyResult,
47
+ VOTE_RULES,
48
+ type BallotCandidateInput,
49
+ type BallotInput,
50
+ } from './src/ballot.ts';
36
51
  import { checkAgainstFrames, checkLines, CheckError, type CheckOptions, type CheckReport } from './src/check.ts';
37
52
  import { compile, CompileError, type CompileOptions } from './src/compile.ts';
38
53
  import { diffLines, diffSkeletons, sectionFigures, type DiffReport } from './src/diff.ts';
@@ -128,31 +143,63 @@ function repositoryUrl(): string {
128
143
  * flag": inferring it would turn `--out --json report.json` — a real typo, a
129
144
  * missing value — into a silently accepted switch plus a stray positional.
130
145
  */
131
- const BOOLEAN_FLAGS = new Set(['all-frames', 'help', 'copy-images']);
146
+ const BOOLEAN_FLAGS = new Set(['all-frames', 'help', 'copy-images', 'again']);
147
+
148
+ /**
149
+ * The flags a command is allowed to spell more than once.
150
+ *
151
+ * Only `vote --candidate` is, because a ballot is *by definition* several
152
+ * candidates. Everywhere else a repeat is a mistake and is refused: `check
153
+ * --candidate a --candidate b` used to take `b` silently, which is a report
154
+ * about a rig the caller did not think they were asking about.
155
+ */
156
+ const REPEATABLE_FLAGS: Record<string, ReadonlySet<string>> = {
157
+ vote: new Set(['candidate']),
158
+ };
132
159
 
133
- /** `--flag value` pairs plus the leftover positionals, in order. */
134
- function parseArgs(argv: string[]): { flags: Record<string, string>; positional: string[] } {
160
+ /**
161
+ * `--flag value` pairs plus the leftover positionals, in order.
162
+ *
163
+ * `lists` carries every occurrence of every flag and `flags` carries the last
164
+ * one, so a command that wants a repeated flag reads `lists` and the ones that
165
+ * do not are untouched by the addition.
166
+ */
167
+ function parseArgs(
168
+ argv: string[],
169
+ repeatable: ReadonlySet<string> = new Set(),
170
+ ): { flags: Record<string, string>; lists: Record<string, string[]>; positional: string[] } {
135
171
  const flags: Record<string, string> = {};
172
+ const lists: Record<string, string[]> = {};
136
173
  const positional: string[] = [];
174
+ const take = (name: string, value: string): void => {
175
+ if (flags[name] !== undefined && !repeatable.has(name)) {
176
+ throw new UsageError(
177
+ `--${name} was given more than once (${JSON.stringify(flags[name])} then ${JSON.stringify(value)}); ` +
178
+ 'this command takes it once',
179
+ );
180
+ }
181
+ flags[name] = value;
182
+ (lists[name] ??= []).push(value);
183
+ };
137
184
  for (let i = 0; i < argv.length; i++) {
138
185
  const arg = argv[i];
139
186
  if (arg.startsWith('--')) {
140
187
  const eq = arg.indexOf('=');
141
188
  if (eq !== -1) {
142
- flags[arg.slice(2, eq)] = arg.slice(eq + 1);
189
+ take(arg.slice(2, eq), arg.slice(eq + 1));
143
190
  } else if (BOOLEAN_FLAGS.has(arg.slice(2))) {
144
- flags[arg.slice(2)] = 'true';
191
+ take(arg.slice(2), 'true');
145
192
  } else {
146
193
  const next = argv[i + 1];
147
194
  if (next === undefined || next.startsWith('--')) throw new UsageError(`${arg} needs a value`);
148
- flags[arg.slice(2)] = next;
195
+ take(arg.slice(2), next);
149
196
  i++;
150
197
  }
151
198
  } else {
152
199
  positional.push(arg);
153
200
  }
154
201
  }
155
- return { flags, positional };
202
+ return { flags, lists, positional };
156
203
  }
157
204
 
158
205
  /**
@@ -726,6 +773,223 @@ function cmdPreview(flags: Record<string, string>): void {
726
773
  console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
727
774
  }
728
775
 
776
+ // ---------------------------------------------------------------------------
777
+ // choosing between results — vote
778
+ // ---------------------------------------------------------------------------
779
+ //
780
+ // ⭐ `preview` shows one candidate; this shows two to four of them side by side
781
+ // and takes an answer back. The rest of this toolchain is instruments, and it
782
+ // should be — the vote opens only where the instruments have already run out.
783
+ // See `src/ballot.ts` for why the ballot is ordered compile-first-vote-last,
784
+ // why the labels are A and B, and why the record is hashes.
785
+ //
786
+ // Two modes on one command, because they share exactly one thing and it is the
787
+ // contract between them: the ballot manifest. Splitting them would document
788
+ // that format twice and let the halves drift.
789
+ //
790
+ // rigc vote --candidate <a> --candidate <b> [--animation <n>] [--out ballot.html]
791
+ // rigc vote --record <result.json> [--ballot ballot.html] [--ledger votes.jsonl] [--again]
792
+
793
+ const DEFAULT_BALLOT = 'ballot.html';
794
+ const DEFAULT_LEDGER = 'votes.jsonl';
795
+
796
+ /** Load one candidate off disk in the shape a ballot needs. */
797
+ function loadBallotCandidate(target: string): { candidate: BallotCandidateInput; animations: string[] } {
798
+ const { skeletonPath, atlasPath } = resolveArtifacts(target, undefined);
799
+ for (const path of [skeletonPath, atlasPath]) {
800
+ if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
801
+ }
802
+ const skeletonText = readFileSync(skeletonPath, 'utf8');
803
+ const atlasText = readFileSync(atlasPath, 'utf8');
804
+ const atlasDir = dirname(atlasPath);
805
+ const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
806
+ const path = join(atlasDir, name);
807
+ if (!existsSync(path)) {
808
+ throw new UsageError(
809
+ `the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
810
+ 'a page a ballot cannot embed is a page the player could not have loaded either',
811
+ );
812
+ }
813
+ return { name, bytes: readFileSync(path) };
814
+ });
815
+ return {
816
+ candidate: { source: skeletonPath, skeletonText, atlasText, pages },
817
+ animations: skeletonAnimationNames(skeletonText, skeletonPath),
818
+ };
819
+ }
820
+
821
+ /**
822
+ * The one animation every candidate plays.
823
+ *
824
+ * ⚠️ Refused rather than resolved per candidate. Two panes running two
825
+ * different animations look like a comparison and are not one, and a voter has
826
+ * no way to see that it happened — the labels are `A` and `B`, which is the
827
+ * whole point, so nothing on the screen would say so.
828
+ */
829
+ function commonAnimation(
830
+ flags: Record<string, string>,
831
+ loaded: { animations: string[] }[],
832
+ ): string | null {
833
+ const asked = flags.animation;
834
+ if (asked === undefined) {
835
+ const first = loaded[0].animations[0];
836
+ if (first === undefined) {
837
+ const withAny = loaded.findIndex((l) => l.animations.length > 0);
838
+ if (withAny !== -1) {
839
+ throw new UsageError(
840
+ `candidate ${withAny + 1} has animations [${loaded[withAny].animations.join(', ')}] and candidate 1 has none — ` +
841
+ 'a ballot plays one animation in every pane, so there is nothing to compare here',
842
+ );
843
+ }
844
+ return null;
845
+ }
846
+ const missing = loaded.findIndex((l) => !l.animations.includes(first));
847
+ if (missing !== -1) {
848
+ throw new UsageError(
849
+ `the default animation is candidate 1's first, ${JSON.stringify(first)}, and candidate ${missing + 1} does not ` +
850
+ `have it (it has [${loaded[missing].animations.join(', ') || 'none'}]); name one they share with --animation`,
851
+ );
852
+ }
853
+ return first;
854
+ }
855
+ const missing = loaded.findIndex((l) => !l.animations.includes(asked));
856
+ if (missing !== -1) {
857
+ throw new UsageError(
858
+ `no animation ${JSON.stringify(asked)} in candidate ${missing + 1}; it has ` +
859
+ `[${loaded[missing].animations.join(', ') || 'none'}]`,
860
+ );
861
+ }
862
+ return asked;
863
+ }
864
+
865
+ /** vote (ballot mode) — write the page a human opens. */
866
+ function cmdVoteBallot(flags: Record<string, string>, candidates: string[]): void {
867
+ if (candidates.length < MIN_CANDIDATES) {
868
+ throw new UsageError(
869
+ `a ballot needs ${MIN_CANDIDATES}–${MAX_CANDIDATES} --candidate <dir | skeleton.json>, and ${candidates.length} ` +
870
+ 'was given — one candidate on its own is `rigc preview`',
871
+ );
872
+ }
873
+ if (candidates.length > MAX_CANDIDATES) {
874
+ throw new UsageError(
875
+ `${candidates.length} candidates were given and a ballot holds at most ${MAX_CANDIDATES} — they go side by side ` +
876
+ 'on one screen, and a comparison that needs scrolling is not a comparison',
877
+ );
878
+ }
879
+ // `--atlas` names ONE atlas and there are several skeletons here, so there is
880
+ // no unambiguous thing it could mean. Each candidate's atlas has to sit beside
881
+ // its skeleton, which is what `build --out` leaves behind.
882
+ if (flags.atlas !== undefined) {
883
+ throw new UsageError(
884
+ '--atlas names one atlas and a ballot has several candidates; each one\'s atlas has to sit beside its skeleton',
885
+ );
886
+ }
887
+
888
+ const loaded = candidates.map((target) => loadBallotCandidate(target));
889
+ const animation = commonAnimation(flags, loaded);
890
+
891
+ const target = resolve(flags.out ?? DEFAULT_BALLOT);
892
+ const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_BALLOT) : target;
893
+
894
+ const input: BallotInput = {
895
+ candidates: loaded.map((l) => l.candidate),
896
+ animation,
897
+ version: readVersion(),
898
+ };
899
+ const { html, manifest } = buildBallot(input);
900
+
901
+ console.log('rigc vote');
902
+ console.log(` .. ballot ${manifest.ballot}`);
903
+ console.log(` .. animation ${animation === null ? '(none — the setup pose)' : animation}`);
904
+ for (let i = 0; i < manifest.candidates.length; i++) {
905
+ const entry = manifest.candidates[i];
906
+ const bytes = loaded[i].candidate.pages.reduce((n, p) => n + p.bytes.length, 0);
907
+ console.log(
908
+ ` .. ${entry.label} ${entry.digest.slice(0, 'sha256:'.length + 12)}… ` +
909
+ `${entry.pages.length} page(s), ${(bytes / 1024).toFixed(1)} KiB <- ${entry.source}`,
910
+ );
911
+ }
912
+ mkdirSync(dirname(out), { recursive: true });
913
+ writeFileSync(out, html);
914
+ console.log(
915
+ ` .. the page shows ${manifest.candidates.map((c) => c.label).join('/')} and nothing else — the paths above are ` +
916
+ 'in its manifest, never on the screen',
917
+ );
918
+ console.log(
919
+ ` .. embedded every candidate's skeleton, atlas and page(s) as data URIs; the player itself loads from ` +
920
+ `unpkg (@${PLAYER_LINE}), so the first open needs a network`,
921
+ );
922
+ console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
923
+ console.log(
924
+ `rigc: then record the saved vote with rigc vote --record ${resultFilename(manifest.ballot)} --ballot ${out}`,
925
+ );
926
+ }
927
+
928
+ /** vote (record mode) — check one saved vote and append it to the ledger. */
929
+ function cmdVoteRecord(flags: Record<string, string>): void {
930
+ for (const key of ['candidate', 'out'] as const) {
931
+ if (flags[key] !== undefined) {
932
+ throw new UsageError(`--record and --${key} are the two halves of this command; run them one at a time`);
933
+ }
934
+ }
935
+ const resultPath = resolve(flags.record);
936
+ const ballotPath = resolve(flags.ballot ?? DEFAULT_BALLOT);
937
+ const ledgerPath = resolve(flags.ledger ?? DEFAULT_LEDGER);
938
+ for (const [what, path] of [
939
+ ['result', resultPath],
940
+ ['ballot', ballotPath],
941
+ ] as const) {
942
+ if (!existsSync(path)) {
943
+ throw new UsageError(
944
+ `no ${what} file at ${path}` + (what === 'ballot' ? ' — name the page this vote came from with --ballot' : ''),
945
+ );
946
+ }
947
+ }
948
+
949
+ console.log('rigc vote --record');
950
+ console.log(` .. result ${resultPath}`);
951
+ console.log(` .. ballot ${ballotPath}`);
952
+ console.log(` .. ledger ${ledgerPath}`);
953
+
954
+ const manifest = readBallotManifest(readFileSync(ballotPath, 'utf8'), ballotPath);
955
+ const result = readJsonFile(resultPath);
956
+ const existing = existsSync(ledgerPath) ? parseLedger(readFileSync(ledgerPath, 'utf8'), ledgerPath) : [];
957
+ const attempts = existing.filter((l) => l.ballot === manifest.ballot).length;
958
+ const again = flags.again !== undefined;
959
+
960
+ const { refusals, line } = verifyResult(manifest, result, { attempts, again });
961
+ if (line === null) {
962
+ for (const refusal of refusals) console.error(` FAIL ${refusal.rule}: ${refusal.detail}`);
963
+ console.error(`rigc: ${refusals.length} refusal(s) — nothing appended to ${ledgerPath}`);
964
+ process.exit(1);
965
+ }
966
+ for (const rule of VOTE_RULES) console.log(` PASS ${rule}`);
967
+
968
+ line.seq = existing.length + 1;
969
+ mkdirSync(dirname(ledgerPath), { recursive: true });
970
+ appendFileSync(ledgerPath, ledgerLineText(line));
971
+ console.log(
972
+ ` .. ${line.choice === TIE ? 'tie' : `winner ${line.choice} = ${line.winner}`}, ` +
973
+ `reason code ${line.reasonCode}${line.attempt > 1 ? `, attempt ${line.attempt}` : ''}`,
974
+ );
975
+ console.log(
976
+ ` .. coverage ${line.coverage.length} candidate(s): ` +
977
+ line.coverage.map((c) => `${c.label}=${c.digest.slice(0, 'sha256:'.length + 12)}…`).join(' '),
978
+ );
979
+ console.log(`rigc: appended line ${line.seq} to ${ledgerPath}`);
980
+ }
981
+
982
+ function cmdVote(flags: Record<string, string>, candidates: string[]): void {
983
+ if (flags.record !== undefined) cmdVoteRecord(flags);
984
+ else if (candidates.length > 0) cmdVoteBallot(flags, candidates);
985
+ else {
986
+ throw new UsageError(
987
+ 'vote takes either 2–4 --candidate <dir | skeleton.json> to write a ballot, or --record <result.json> to ' +
988
+ 'record one that came back',
989
+ );
990
+ }
991
+ }
992
+
729
993
  /**
730
994
  * bench — run one rung of the benchmark ladder against a candidate rig.
731
995
  *
@@ -1012,6 +1276,46 @@ function cmdExplain(flags: Record<string, string>): void {
1012
1276
  }
1013
1277
  }
1014
1278
  }
1279
+ // The two constraint groups: one unnamed timeline per constraint, so the
1280
+ // name printed is the constraint's and there is no timeline name to print
1281
+ // beside it. Every field a key carries is shown, because each one is
1282
+ // optional in the file and the ABSENT ones are what a reader has to see —
1283
+ // an omitted `softness` is 0, not "unchanged".
1284
+ for (const group of ['ik', 'transform'] as const) {
1285
+ for (const [name, keys] of Object.entries(anim[group] ?? {})) {
1286
+ console.log(` ${group}.${name} ${keys.length} key(s) <- one timeline per constraint`);
1287
+ for (const key of keys) {
1288
+ const fields = Object.entries(key)
1289
+ .filter(([k]) => k !== 'time' && k !== 'curve')
1290
+ .map(([k, v]) => `${k}=${String(v)}`)
1291
+ .join(' ');
1292
+ const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
1293
+ console.log(` t=${String(key.time).padEnd(7)} ${(fields || '(all defaults)').padEnd(46)} ${curve}`);
1294
+ }
1295
+ }
1296
+ }
1297
+ // Deform timelines are keyed on a skin/slot/attachment triple, and the run
1298
+ // is printed as its span rather than its numbers: `offset` plus a length is
1299
+ // what tells a reader whether the key lands where they meant, and a hundred
1300
+ // vertex offsets on one line tells them nothing.
1301
+ for (const [skinName, slotMap] of Object.entries(anim.attachments ?? {})) {
1302
+ for (const [slotName, attMap] of Object.entries(slotMap)) {
1303
+ for (const [attName, timelines] of Object.entries(attMap)) {
1304
+ for (const [timelineName, keys] of Object.entries(timelines)) {
1305
+ console.log(` ${skinName}/${slotName}/${attName}.${timelineName} ${keys.length} key(s)`);
1306
+ for (const key of keys) {
1307
+ const run = Array.isArray(key.vertices) ? (key.vertices as number[]) : null;
1308
+ const offset = typeof key.offset === 'number' ? key.offset : 0;
1309
+ const span = run
1310
+ ? `deform[${offset}..${offset + run.length}] ${run.length / 2} pair(s)`
1311
+ : 'back to the setup pose';
1312
+ const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
1313
+ console.log(` t=${String(key.time).padEnd(7)} ${span.padEnd(46)} ${curve}`);
1314
+ }
1315
+ }
1316
+ }
1317
+ }
1318
+ }
1015
1319
  // The draw-order timeline names no target, so it hangs off the animation
1016
1320
  // rather than off a slot — and a timeline `explain` did not print would be a
1017
1321
  // timeline nobody could check without reading the emitted JSON.
@@ -1088,6 +1392,10 @@ const FLAG_MEANINGS: Record<string, string> = {
1088
1392
  json: 'also write the whole report to this path',
1089
1393
  animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
1090
1394
  max: 'longest side of a rendered frame, in pixels (default 256)',
1395
+ record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
1396
+ ballot: `the ballot the --record'd vote answers (default \`${DEFAULT_BALLOT}\`); its embedded manifest is what the vote is checked against`,
1397
+ ledger: `the append-only JSONL the vote lands in (default \`${DEFAULT_LEDGER}\`)`,
1398
+ again: 'record a second vote on a ballot the ledger already has; without it, a repeat is refused rather than doubled',
1091
1399
  help: "show this command's flags and exit",
1092
1400
  };
1093
1401
 
@@ -1111,6 +1419,9 @@ const FLAG_VALUES: Record<string, string> = {
1111
1419
  json: '<out>',
1112
1420
  animation: '<name>',
1113
1421
  max: '<px>',
1422
+ record: '<result.json>',
1423
+ ballot: '<ballot.html>',
1424
+ ledger: '<votes.jsonl>',
1114
1425
  };
1115
1426
 
1116
1427
  interface CommandDoc {
@@ -1123,12 +1434,14 @@ interface CommandDoc {
1123
1434
  * Per-command wording for a flag whose value or meaning genuinely differs here.
1124
1435
  *
1125
1436
  * ⚠️ The default above it — one meaning per flag name, everywhere — is the rule
1126
- * and this is the named exception to it, not a second table. Two flags earn it:
1437
+ * and this is the named exception to it, not a second table. Three flags earn it:
1127
1438
  * `--out` is a directory of artifacts to `build`, a directory of pictures to
1128
- * `render` and one file to `preview`; `--fps` is the rate a frame set was
1129
- * RECORDED at to `check`, which reads it off a sidecar, and the rate to SAMPLE
1130
- * at to `render`, which is choosing it. Writing either as one sentence covering
1131
- * every command would leave every command's own help less true than it is now.
1439
+ * `render` and one file to `preview` and `vote`; `--fps` is the rate a frame set
1440
+ * was RECORDED at to `check`, which reads it off a sidecar, and the rate to
1441
+ * SAMPLE at to `render`, which is choosing it; `--candidate` is one artifact
1442
+ * everywhere except `vote`, which is the one command that takes several and is
1443
+ * the reason there is a ballot at all. Writing any of them as one sentence
1444
+ * covering every command would leave every command's own help less true.
1132
1445
  */
1133
1446
  overrides?: Record<string, { value?: string; meaning?: string }>;
1134
1447
  }
@@ -1192,6 +1505,29 @@ const COMMANDS: CommandDoc[] = [
1192
1505
  },
1193
1506
  },
1194
1507
  },
1508
+ {
1509
+ name: 'vote',
1510
+ usage: [
1511
+ `rigc vote --candidate <dir | skeleton.json> --candidate <…> [--candidate …] [--animation <name>] [--out ${DEFAULT_BALLOT}]`,
1512
+ `rigc vote --record <result.json> [--ballot ${DEFAULT_BALLOT}] [--ledger ${DEFAULT_LEDGER}] [--again]`,
1513
+ ],
1514
+ flags: ['candidate', 'animation', 'out', 'record', 'ballot', 'ledger', 'again'],
1515
+ overrides: {
1516
+ candidate: {
1517
+ value: '<dir|skeleton.json>',
1518
+ meaning: `repeat it ${MIN_CANDIDATES}–${MAX_CANDIDATES} times — one compiled artifact per pane, labelled A, B, C, D in the order given`,
1519
+ },
1520
+ animation: {
1521
+ meaning:
1522
+ 'the one animation every pane plays (default: the first of candidate A). A candidate that does not have ' +
1523
+ 'it is refused — two panes playing two animations is not a comparison',
1524
+ },
1525
+ out: {
1526
+ value: '<file>',
1527
+ meaning: `the .html ballot to write (default \`${DEFAULT_BALLOT}\`); a directory means "the default name in here"`,
1528
+ },
1529
+ },
1530
+ },
1195
1531
  ];
1196
1532
 
1197
1533
  const KNOWN_COMMANDS = COMMANDS.map((c) => c.name);
@@ -1226,7 +1562,7 @@ const USAGE = [
1226
1562
  ' THE DEFAULT — 20 rules, and the question the output answers when',
1227
1563
  ' you import it into the Spine editor.',
1228
1564
  ' spine-html the above, plus this project\'s renderer and archetype policy:',
1229
- ' all 34 rules, opt-in. Those extra 14 fire on real, correct,',
1565
+ ' all 36 rules, opt-in. Those extra 14 fire on real, correct,',
1230
1566
  ' editor-produced Spine data, so they are somebody\'s policy rather',
1231
1567
  ' than anybody\'s validity.',
1232
1568
  '',
@@ -1246,6 +1582,14 @@ const USAGE = [
1246
1582
  'draws with rigc\'s own rasteriser; preview embeds the artifact in a page that plays',
1247
1583
  'it in the official Spine Web Player, which is also the interop proof.',
1248
1584
  '',
1585
+ 'vote is the same page with two to four builds in it and an answer coming back:',
1586
+ ' rigc vote --candidate <build A> --candidate <build B> ballot.html, panes labelled A and B',
1587
+ ' rigc vote --record vote-<id>.json --ballot ballot.html check it, append it to votes.jsonl',
1588
+ 'Reach for it where the instruments have run out — a choice with no reference behind',
1589
+ 'it, two fits that measure the same. The panes carry no paths, a tie is a recorded',
1590
+ 'answer rather than a missing one, and a result whose hashes are not the ballot\'s is',
1591
+ 'refused by name instead of appended.',
1592
+ '',
1249
1593
  'a cuts.json is { "<name>": { "rig": "...", "motion": "...", "out": "...",',
1250
1594
  ' "manifest": "..." (optional) } }, with every path',
1251
1595
  'resolved relative to the cuts.json file itself.',
@@ -1269,7 +1613,7 @@ try {
1269
1613
  throw new UsageError(`unknown command: ${command}`);
1270
1614
  }
1271
1615
 
1272
- const { flags, positional } = parseArgs(rest);
1616
+ const { flags, lists, positional } = parseArgs(rest, REPEATABLE_FLAGS[command]);
1273
1617
  if (flags.help !== undefined) {
1274
1618
  console.log(commandHelp(command));
1275
1619
  process.exit(0);
@@ -1282,11 +1626,18 @@ try {
1282
1626
  else if (command === 'bench') cmdBench(flags, positional);
1283
1627
  else if (command === 'render') cmdRender(flags);
1284
1628
  else if (command === 'preview') cmdPreview(flags);
1629
+ else if (command === 'vote') cmdVote(flags, lists.candidate ?? []);
1285
1630
  } catch (err) {
1286
1631
  if (err instanceof UsageError) {
1287
1632
  console.error(`rigc: ${err.message}\n\n${USAGE}`);
1288
1633
  process.exit(2);
1289
1634
  }
1635
+ // A ballot refuses on its arguments, like a usage error, but its messages are
1636
+ // long enough that reprinting the whole usage under them buries the reason.
1637
+ if (err instanceof BallotError) {
1638
+ console.error(`rigc vote: ${err.message}`);
1639
+ process.exit(2);
1640
+ }
1290
1641
  if (err instanceof CompileError) {
1291
1642
  console.error(`rigc compile error: ${err.message}`);
1292
1643
  process.exit(1);