@remits/remits-cli 0.1.157 → 0.1.158

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/index.js CHANGED
@@ -12,10 +12,10 @@
12
12
  - L4591 Auth And Component Staging
13
13
  - L5223 Component Summaries, Status, And Sync Gates
14
14
  - L7547 Branches, Promotion, Commit, And Test Runs
15
- - L9676 Tokens, Tools, Verification, And Config
16
- - L11174 Service Dashboard And WebSocket Listener
17
- - L13673 Agent And Ticket Workflows
18
- - L17849 Help, Auto Update, And Command Dispatch
15
+ - L9790 Tokens, Tools, Verification, And Config
16
+ - L11307 Service Dashboard And WebSocket Listener
17
+ - L13806 Agent And Ticket Workflows
18
+ - L17982 Help, Auto Update, And Command Dispatch
19
19
  */
20
20
 
21
21
  /*
@@ -112,7 +112,7 @@ let LOCAL_ACTOR_CONTEXT = null;
112
112
 
113
113
  // Flags that are legitimately repeatable accumulate into an array instead of last-wins. Every other
114
114
  // flag keeps last-wins so existing callers are unaffected.
115
- const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved', 'names', 'tag', 'tags', 'key', 'keys', 'case-key', 'score', 'scores', 'lower-is-better']);
115
+ const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved', 'names', 'tag', 'tags', 'key', 'keys', 'case-key', 'score', 'scores', 'lower-is-better', 'param', 'params']);
116
116
 
117
117
  // `-m "msg"` is how git spells a commit message, and agents type it from habit. Before this the parser took a
118
118
  // single-dash token as the VALUE of whatever flag preceded it: `--json -m "x"` silently turned JSON mode
@@ -9156,7 +9156,9 @@ function watchWebsocket(baseUrl, topicId, taskId, options = {}) {
9156
9156
  const data = JSON.parse(msg.body);
9157
9157
  if (data.type === 'TestSuite') {
9158
9158
  const payload = data.payload || {};
9159
- if (payload.taskId && taskId && payload.taskId !== taskId) {
9159
+ // A parallel run is followed by every shard's task id.
9160
+ const followed = Array.isArray(taskId) ? taskId : (taskId ? [taskId] : []);
9161
+ if (payload.taskId && followed.length && !followed.includes(payload.taskId)) {
9160
9162
  return;
9161
9163
  }
9162
9164
  if (payload.tests || payload.name || payload.total != null) {
@@ -9179,6 +9181,82 @@ function watchWebsocket(baseUrl, topicId, taskId, options = {}) {
9179
9181
  };
9180
9182
  }
9181
9183
 
9184
+ // `--param name=value` (repeatable) -> the map a suite reads with runParams(). A value that is a JSON number,
9185
+ // boolean, null, array or object is passed as that JSON; anything else is the literal text, so `--param model=x`
9186
+ // needs no quoting. A name without `=` is refused rather than guessed at.
9187
+ function parseRunParams(raw) {
9188
+ const values = raw === undefined || raw === null || raw === true ? [] : (Array.isArray(raw) ? raw : [raw]);
9189
+ const params = {};
9190
+ values.forEach((entry) => {
9191
+ const text = String(entry);
9192
+ const eq = text.indexOf('=');
9193
+ if (eq <= 0) throw new Error('--param expects name=value (got "' + text + '")');
9194
+ const name = text.slice(0, eq).trim();
9195
+ const value = text.slice(eq + 1);
9196
+ let parsed = value;
9197
+ const trimmed = value.trim();
9198
+ if (/^(-?\d+(\.\d+)?([eE][+-]?\d+)?|true|false|null)$/.test(trimmed) || /^(\[[\s\S]*\]|\{[\s\S]*\})$/.test(trimmed)) {
9199
+ try {
9200
+ parsed = JSON.parse(trimmed);
9201
+ } catch (err) {
9202
+ parsed = value;
9203
+ }
9204
+ }
9205
+ params[name] = parsed;
9206
+ });
9207
+ return params;
9208
+ }
9209
+
9210
+ // How `test run` labels, parameterises, repeats and shards a run, from its flags. Validation of the values is the
9211
+ // platform's (Test.normalizeRunOptions); this only shapes the request and checks what the CLI itself acts on.
9212
+ function testRunOptionsFromFlags(flags = {}) {
9213
+ const label = flags.label === undefined || flags.label === true ? undefined : String(flags.label);
9214
+ const params = parseRunParams(flags.param !== undefined ? flags.param : flags.params);
9215
+ const repeat = flags.repeat === undefined ? undefined : parsePositiveInt(flags.repeat, NaN);
9216
+ if (flags.repeat !== undefined && !Number.isFinite(repeat)) throw new Error('--repeat expects a whole number of runs per case (got "' + flags.repeat + '")');
9217
+ const parallel = flags.parallel === undefined ? 1 : parsePositiveInt(flags.parallel, NaN);
9218
+ if (!Number.isFinite(parallel)) throw new Error('--parallel expects a whole number of shards (got "' + flags.parallel + '")');
9219
+ return {
9220
+ label,
9221
+ params: Object.keys(params).length ? params : undefined,
9222
+ repeat: repeat && repeat > 1 ? repeat : undefined,
9223
+ parallel
9224
+ };
9225
+ }
9226
+
9227
+ // How long a later shard of a parallel run waits for a free suite slot before the launch gives up.
9228
+ const SHARD_ADMISSION_WAIT_MS = 30 * 60 * 1000;
9229
+
9230
+ // A start the platform refused because every suite slot on the instance is taken (safe to repeat: no work began).
9231
+ function shardStartBusy(err) {
9232
+ const response = err && err.response;
9233
+ return !!(response && response.status === 429 && response.data && response.data.code === 'WORKLOAD_BUSY');
9234
+ }
9235
+
9236
+ // One start request per shard of a `--parallel N` run, all under one group id: shard i of N runs every Nth
9237
+ // selected case. The platform reads the group id as ONE run in status, corpus runs/compare/gate.
9238
+ function shardRunPayloads(runOptions = {}, groupId) {
9239
+ const count = Math.max(1, Number(runOptions.parallel || 1));
9240
+ const common = { label: runOptions.label, params: runOptions.params, repeat: runOptions.repeat };
9241
+ if (count <= 1) return [common];
9242
+ return Array.from({ length: count }, (_, i) => Object.assign({}, common, {
9243
+ runGroupId: groupId,
9244
+ shard: { index: i + 1, count }
9245
+ }));
9246
+ }
9247
+
9248
+ // What a launch asked for beyond the suite itself: label, params, repetitions and shards.
9249
+ function testRunLaunchLines(start = {}, runOptions = {}) {
9250
+ const lines = [];
9251
+ if (runOptions.label) lines.push('Label: ' + runOptions.label);
9252
+ if (runOptions.params) lines.push('Params: ' + Object.keys(runOptions.params).map((k) => k + '=' + JSON.stringify(runOptions.params[k])).join(' '));
9253
+ if (runOptions.repeat) lines.push('Repeat: every selected case runs ' + runOptions.repeat + ' times');
9254
+ if (start.runGroupId) {
9255
+ lines.push('Parallel: ' + (start.shardTaskIds || []).length + ' shards (' + (start.shardTaskIds || []).join(', ') + '), read as ONE run by ' + start.runGroupId);
9256
+ }
9257
+ return lines;
9258
+ }
9259
+
9182
9260
  async function testCommand(flags) {
9183
9261
  const cwd = process.cwd();
9184
9262
  ensureLocalState(cwd);
@@ -9207,6 +9285,9 @@ async function testCommand(flags) {
9207
9285
  ? []
9208
9286
  : (Array.isArray(flags.names) ? flags.names : [flags.names]);
9209
9287
  const names = parseTestCaseSelectors(rawNames);
9288
+ // --label / --param / --repeat / --parallel: what this run varies, and how it is executed.
9289
+ const runOptions = testRunOptionsFromFlags(flags);
9290
+ const runGroupId = runOptions.parallel > 1 ? 'grp-' + crypto.randomUUID() : undefined;
9210
9291
 
9211
9292
  const api = buildAxios(baseUrl, session.token);
9212
9293
  // --as-account runs the Test AS a (descendant) subscriber account, so that account's relationship
@@ -9243,7 +9324,7 @@ async function testCommand(flags) {
9243
9324
  });
9244
9325
  });
9245
9326
 
9246
- const start = await loggedPost(api, cwd, '/cli/test', {
9327
+ const startRequest = {
9247
9328
  token: session.token,
9248
9329
  accountId,
9249
9330
  asAccountId,
@@ -9255,11 +9336,43 @@ async function testCommand(flags) {
9255
9336
  testId: /^\d+$/.test(String(testRef)) ? Number(testRef) : undefined,
9256
9337
  testName: /^\d+$/.test(String(testRef)) ? undefined : String(testRef),
9257
9338
  tests: names
9258
- }).then((r) => r.data);
9259
-
9260
- if (!start.success) {
9261
- throw new Error('Failed to start test run');
9339
+ };
9340
+ // Shards are started one after another (each start returns at once) and then run side by side on the platform.
9341
+ const shardStarts = [];
9342
+ // The platform admits only a few suites per instance at once and refuses the rest as WORKLOAD_BUSY. Once one
9343
+ // of our shards is running it holds a slot it will give back, so a later shard keeps waiting for one (bounded)
9344
+ // instead of giving up after the usual retries and leaving a group that can never finish.
9345
+ const shardWaitDeadline = Date.now() + SHARD_ADMISSION_WAIT_MS;
9346
+ for (const shardOptions of shardRunPayloads(runOptions, runGroupId)) {
9347
+ let shardStart;
9348
+ for (;;) {
9349
+ try {
9350
+ shardStart = await loggedPost(api, cwd, '/cli/test', Object.assign({}, startRequest, shardOptions)).then((r) => r.data);
9351
+ break;
9352
+ } catch (err) {
9353
+ const data = err && err.response && err.response.data;
9354
+ if (shardStarts.length && shardStartBusy(err) && Date.now() < shardWaitDeadline) {
9355
+ console.error('Shard ' + (shardStarts.length + 1) + ' of ' + runOptions.parallel + ' is waiting for a free suite slot ' +
9356
+ '(this platform instance runs a limited number of suites at once; the shards already running free them).');
9357
+ await new Promise((resolve) => setTimeout(resolve, 10000));
9358
+ continue;
9359
+ }
9360
+ const started = shardStarts.length ? ' (shard ' + (shardStarts.length + 1) + ' of ' + runOptions.parallel +
9361
+ ' never started; the shards already started keep running: ' + shardStarts.map((st) => st.taskId).join(', ') + ')' : '';
9362
+ if (data && data.message) throw new Error('Failed to start test run: ' + data.message + started);
9363
+ throw err;
9364
+ }
9365
+ }
9366
+ if (!shardStart.success) {
9367
+ throw new Error('Failed to start test run' + (shardStarts.length ? ' (shard ' + (shardStarts.length + 1) + ' of ' + runOptions.parallel +
9368
+ '; the shards already started keep running: ' + shardStarts.map((st) => st.taskId).join(', ') + ')' : ''));
9369
+ }
9370
+ shardStarts.push(shardStart);
9262
9371
  }
9372
+ // One run to follow: the single run, or the group whose status the platform merges from every shard.
9373
+ const start = runGroupId
9374
+ ? Object.assign({}, shardStarts[0], { taskId: runGroupId, runGroupId, shardTaskIds: shardStarts.map((s) => s.taskId) })
9375
+ : shardStarts[0];
9263
9376
 
9264
9377
  withStdoutRoutedToStderr(jsonOutput, () => {
9265
9378
  printSessionResolutionWarning(sessionContext);
@@ -9281,6 +9394,7 @@ async function testCommand(flags) {
9281
9394
  gitHead: safeGitValue(cwd, 'git rev-parse HEAD')
9282
9395
  }).forEach((line) => console.log(line));
9283
9396
  console.log('Test run started:', start.taskId);
9397
+ testRunLaunchLines(start, runOptions).forEach((line) => console.log(line));
9284
9398
  });
9285
9399
  runtimeState.currentTestTaskId = start.taskId;
9286
9400
 
@@ -9330,7 +9444,7 @@ async function testCommand(flags) {
9330
9444
  if (flags.watch !== 'false') {
9331
9445
  const topic = session.websocketTopic || start.websocketTopic || (session.user && String(session.user.uuid || '').replace(/-/g, ''));
9332
9446
  if (topic) {
9333
- stopWs = watchWebsocket(baseUrl, topic, start.taskId, { stderr: jsonOutput, verbose: flagEnabled(flags.verbose) });
9447
+ stopWs = watchWebsocket(baseUrl, topic, start.shardTaskIds || start.taskId, { stderr: jsonOutput, verbose: flagEnabled(flags.verbose) });
9334
9448
  }
9335
9449
  }
9336
9450
 
@@ -10345,6 +10459,7 @@ function corpusAiLabel(row = {}) {
10345
10459
 
10346
10460
  function corpusScoreSummary(scores = {}) {
10347
10461
  return Object.keys(scores).map((k) => k + ' ' + (scores[k] && scores[k].mean !== undefined ? scores[k].mean : scores[k]) +
10462
+ (scores[k] && scores[k].stderr !== undefined ? ' \u00b1' + scores[k].stderr : '') +
10348
10463
  (scores[k] && scores[k].cases !== undefined ? '/' + scores[k].cases : '')).join(', ');
10349
10464
  }
10350
10465
 
@@ -10352,7 +10467,8 @@ function corpusScoreDeltas(scores = {}) {
10352
10467
  return Object.keys(scores).map((k) => {
10353
10468
  const s = scores[k] || {};
10354
10469
  const delta = s.delta !== undefined && s.delta !== null ? ' (' + (Number(s.delta) >= 0 ? '+' : '') + s.delta + ')' : '';
10355
- return k + ' ' + (s.base !== undefined ? s.base : '?') + ' -> ' + (s.head !== undefined ? s.head : '?') + delta + (s.regressed ? ' REGRESSED' : '');
10470
+ const noise = s.stderr !== undefined ? ' [se ' + s.stderr + (s.withinNoise ? ', within noise' : '') + ']' : '';
10471
+ return k + ' ' + (s.base !== undefined ? s.base : '?') + ' -> ' + (s.head !== undefined ? s.head : '?') + delta + noise + (s.regressed ? ' REGRESSED' : '');
10356
10472
  }).join(', ');
10357
10473
  }
10358
10474
 
@@ -10364,16 +10480,22 @@ function corpusLines(sub, data = {}) {
10364
10480
  cases.forEach((c) => lines.push('- ' + c.caseKey + (c.split ? ' [' + c.split + ']' : '') + (c.tags && c.tags.length ? ' tags ' + c.tags.join(',') : '') +
10365
10481
  ' expected ' + (c.hasExpected ? (c.expectedStatus || 'present') : 'none') + ' artifacts ' + (c.artifacts || []).map((a) => a.name + ':' + a.bytes + 'b').join(' ')));
10366
10482
  } else if (sub === 'results') {
10367
- (data.results || []).forEach((r) => lines.push('- ' + r.caseKey + ' ' + (r.outcome || '?') + (r.score !== undefined ? ' score ' + r.score : '') +
10368
- ' run ' + r.taskId + ' ' + corpusAiLabel(r) + (r.durationMs ? ' ' + formatDurationMs(r.durationMs) : '') +
10483
+ (data.results || []).forEach((r) => lines.push('- ' + r.caseKey + (r.repeatIndex ? ' #' + r.repeatIndex : '') + ' ' + (r.outcome || '?') + (r.score !== undefined ? ' score ' + r.score : '') +
10484
+ ' run ' + (r.runGroupId || r.taskId) + (r.runLabel ? ' (' + r.runLabel + ')' : '') + (r.split ? ' [' + r.split + ']' : '') + ' ' + corpusAiLabel(r) + (r.durationMs ? ' ' + formatDurationMs(r.durationMs) : '') +
10369
10485
  ' metrics ' + String(r.metricsHash || '').slice(0, 10) + ' ' + formatTime(r.recordedAt)));
10370
10486
  } else if (sub === 'runs') {
10371
- (data.runs || []).forEach((r) => lines.push('- ' + r.taskId + ' ' + r.cases + ' case(s) ' + Object.keys(r.outcomes || {}).map((k) => k + ' ' + r.outcomes[k]).join(', ') +
10487
+ (data.runs || []).forEach((r) => lines.push('- ' + r.taskId + (r.label ? ' "' + r.label + '"' : '') + ' ' + r.cases + ' case(s)' +
10488
+ (r.repeats ? ' x' + r.repeats + ' repeats' : '') + (r.shards ? ' ' + r.shards + ' shards' : '') + ' ' +
10489
+ Object.keys(r.outcomes || {}).map((k) => k + ' ' + r.outcomes[k]).join(', ') + (r.passRate !== undefined ? ' pass rate ' + r.passRate : '') +
10372
10490
  ' ' + corpusAiLabel(r) + ' ' + (r.dataMode || '?') + ' ' + (r.branchName || '?') + (r.workspace ? ' [ws:' + r.workspace + ']' : '') +
10373
10491
  (r.laneContentHash ? ' lane ' + String(r.laneContentHash).slice(0, 12) : '') + ' ' + formatTime(r.recordedAt)));
10374
10492
  (data.runs || []).forEach((r) => {
10375
10493
  const extra = [];
10494
+ if (r.params && Object.keys(r.params).length) extra.push('params ' + Object.keys(r.params).map((k) => k + '=' + JSON.stringify(r.params[k])).join(' '));
10376
10495
  if (r.scores && Object.keys(r.scores).length) extra.push('scores ' + corpusScoreSummary(r.scores));
10496
+ if (r.bySplit) extra.push('by split ' + Object.keys(r.bySplit).map((sp) => sp + ': ' + r.bySplit[sp].cases + ' case(s)' +
10497
+ (r.bySplit[sp].passRate !== undefined ? ' pass ' + r.bySplit[sp].passRate : '') +
10498
+ (r.bySplit[sp].scores ? ' ' + corpusScoreSummary(r.bySplit[sp].scores) : '')).join('; '));
10377
10499
  if (r.review) extra.push('graded ' + r.review.graded + ', spot-checked ' + r.review.reviewed + (r.review.agreement !== undefined ? ' (agreement ' + r.review.agreement + ')' : ''));
10378
10500
  if (extra.length) lines.push(' ' + r.taskId + ': ' + extra.join(' '));
10379
10501
  });
@@ -10388,6 +10510,7 @@ function corpusLines(sub, data = {}) {
10388
10510
  if (cmp.scores && Object.keys(cmp.scores).length) lines.push('Scores (cases both runs measured): ' + corpusScoreDeltas(cmp.scores));
10389
10511
  (cmp.cases || []).filter((c) => c.direction !== 'same' || c.changedScores).forEach((c) => {
10390
10512
  lines.push('- ' + c.caseKey + ' ' + c.direction + (c.base ? ' ' + c.base.outcome : '') + (c.head ? ' -> ' + c.head.outcome : '') +
10513
+ (c.base && c.base.passRate !== undefined ? ' pass ' + c.base.passRate + ' -> ' + (c.head && c.head.passRate !== undefined ? c.head.passRate : '?') : '') +
10391
10514
  (c.expectedChanged ? ' (EXPECTED ANSWER CHANGED between runs)' : ''));
10392
10515
  (c.changedMetrics || []).slice(0, 8).forEach((m) => lines.push(' ' + m.metric + ': ' + JSON.stringify(m.base) + ' -> ' + JSON.stringify(m.head)));
10393
10516
  Object.keys(c.changedScores || {}).slice(0, 8).forEach((k) => lines.push(' score ' + k + ': ' + c.changedScores[k].base + ' -> ' + c.changedScores[k].head));
@@ -10404,9 +10527,11 @@ function corpusLines(sub, data = {}) {
10404
10527
  } else if (sub === 'gate') {
10405
10528
  const gate = data.gate || {};
10406
10529
  lines.push('Gate ' + (gate.baseTaskId || '?') + ' -> ' + (gate.headTaskId || '?') + ': ' + (gate.passed ? 'PASSED' : 'DID NOT PASS') +
10407
- (gate.tolerance !== undefined ? ' (tolerance ' + gate.tolerance + ')' : ''));
10530
+ (gate.tolerance !== undefined ? ' (tolerance ' + gate.tolerance + ')' : '') + (gate.noiseSigma !== undefined ? ' (noise sigma ' + gate.noiseSigma + ')' : '') +
10531
+ (gate.split ? ' split ' + gate.split : ''));
10408
10532
  if (gate.scores && Object.keys(gate.scores).length) lines.push('Scores: ' + corpusScoreDeltas(gate.scores));
10409
10533
  (gate.reasons || []).forEach((r) => lines.push('- ' + r));
10534
+ (gate.notes || []).forEach((n) => lines.push('Note: ' + n));
10410
10535
  if (gate.expectedChanged && gate.expectedChanged.length) lines.push('Left out (expected answer changed between runs): ' + gate.expectedChanged.join(', '));
10411
10536
  (gate.changedCases || []).slice(0, 15).forEach((c) => lines.push(' ' + c.caseKey + ' ' + c.direction +
10412
10537
  Object.keys(c.changedScores || {}).map((k) => ' ' + k + ' ' + c.changedScores[k].base + '->' + c.changedScores[k].head).join('')));
@@ -10428,7 +10553,7 @@ function corpusLines(sub, data = {}) {
10428
10553
  lines.push('Case ' + con.caseKey + ': ' + con.runs + ' run(s), ' + (con.stable ? 'STABLE (identical metrics every run)' : (con.variants || []).length + ' distinct metric results') +
10429
10554
  (con.aiModes && con.aiModes.length ? ' AI ' + con.aiModes.join('+') : ''));
10430
10555
  if (con.note) lines.push('WARNING: ' + con.note);
10431
- (con.variants || []).forEach((v) => lines.push('- metrics ' + String(v.metricsHash).slice(0, 10) + ' runs ' + (v.runs || []).map((r) => r.taskId + ':' + r.outcome + (r.aiMode && r.aiMode !== 'none' ? '/' + r.aiMode : '')).join(', ')));
10556
+ (con.variants || []).forEach((v) => lines.push('- metrics ' + String(v.metricsHash).slice(0, 10) + ' runs ' + (v.runs || []).map((r) => r.taskId + (r.repeatIndex ? '#' + r.repeatIndex : '') + ':' + r.outcome + (r.aiMode && r.aiMode !== 'none' ? '/' + r.aiMode : '')).join(', ')));
10432
10557
  }
10433
10558
  return lines;
10434
10559
  }
@@ -10538,12 +10663,16 @@ async function corpusCommand(flags, subcommand) {
10538
10663
  const keys = parseCorpusFilterValues(flags, ['key', 'keys', 'case', 'case-key']);
10539
10664
  data = await post({ command: 'cases', corpus: corpusName, split: flags.split, tags: tags.length ? tags : undefined, keys: keys.length ? keys : undefined, includeValues: flagEnabled(flags['include-values']), includeRetired: flagEnabled(flags['include-retired']), limit: flags.limit });
10540
10665
  } else if (sub === 'results') {
10541
- data = await post({ command: 'results', corpus: corpusName, caseKey: flags.case || flags['case-key'], taskId: flags['task-id'], limit: flags.limit || 50 });
10666
+ const tags = parseCorpusFilterValues(flags, ['tag', 'tags']);
10667
+ data = await post({ command: 'results', corpus: corpusName, caseKey: flags.case || flags['case-key'], taskId: flags['task-id'], label: flags.label,
10668
+ split: flags.split, tags: tags.length ? tags : undefined, limit: flags.limit || 50 });
10542
10669
  } else if (sub === 'runs') {
10543
- data = await post({ command: 'runs', corpus: corpusName, limit: flags.limit || 20 });
10670
+ const tags = parseCorpusFilterValues(flags, ['tag', 'tags']);
10671
+ data = await post({ command: 'runs', corpus: corpusName, label: flags.label, split: flags.split, tags: tags.length ? tags : undefined, limit: flags.limit || 20 });
10544
10672
  } else if (sub === 'compare') {
10545
10673
  if (!flags.base || !flags.head) throw new Error('corpus compare requires --base <taskId> --head <taskId>');
10546
- data = await post({ command: 'compare', corpus: corpusName, base: flags.base, head: flags.head });
10674
+ const tags = parseCorpusFilterValues(flags, ['tag', 'tags']);
10675
+ data = await post({ command: 'compare', corpus: corpusName, base: flags.base, head: flags.head, split: flags.split, tags: tags.length ? tags : undefined });
10547
10676
  } else if (sub === 'consistency') {
10548
10677
  const caseKey = flags.case || flags['case-key'];
10549
10678
  if (!caseKey) throw new Error('corpus consistency requires --case <caseKey>');
@@ -10556,10 +10685,13 @@ async function corpusCommand(flags, subcommand) {
10556
10685
  } else if (sub === 'gate') {
10557
10686
  const scores = parseCorpusFilterValues(flags, ['score', 'scores']);
10558
10687
  const lowerIsBetter = parseCorpusFilterValues(flags, ['lower-is-better']);
10688
+ const tags = parseCorpusFilterValues(flags, ['tag', 'tags']);
10559
10689
  data = await post({ command: 'gate', corpus: corpusName, base: flags.base, head: flags.head, tolerance: flags.tolerance,
10560
10690
  scores: scores.length ? scores : undefined,
10561
10691
  lowerIsBetter: lowerIsBetter.length ? lowerIsBetter : undefined,
10562
- allowModeChange: flagEnabled(flags['allow-mode-change']) });
10692
+ allowModeChange: flagEnabled(flags['allow-mode-change']),
10693
+ baseLabel: flags['base-label'], headLabel: flags['head-label'], split: flags.split, tags: tags.length ? tags : undefined,
10694
+ minCases: flags['min-cases'], noiseSigma: flags['noise-sigma'] });
10563
10695
  const gate = (data && data.gate) || {};
10564
10696
  if (!gate.passed) process.exitCode = 1;
10565
10697
  await appendVerificationPacket(api, cwd, session, accountId, flags, {
@@ -10569,7 +10701,8 @@ async function corpusCommand(flags, subcommand) {
10569
10701
  world: Object.assign({}, (data && data.world) || {}, { host: normalizeBaseUrl(baseUrl), branchName, workspace, dataMode }),
10570
10702
  revision: collectVerificationSource(cwd, flags),
10571
10703
  evidenceCategories: ['corpus_gate'],
10572
- corpusGate: { corpus: corpusName, baseTaskId: gate.baseTaskId, headTaskId: gate.headTaskId, passed: !!gate.passed, scores: gate.scores, reasons: gate.reasons }
10704
+ corpusGate: { corpus: corpusName, baseTaskId: gate.baseTaskId, headTaskId: gate.headTaskId, passed: !!gate.passed, scores: gate.scores, reasons: gate.reasons,
10705
+ split: gate.split, noiseSigma: gate.noiseSigma, minCases: gate.minCases, notes: gate.notes }
10573
10706
  }, { quiet: jsonOutput });
10574
10707
  } else if (sub === 'review') {
10575
10708
  const taskId = flags['task-id'];
@@ -10577,7 +10710,7 @@ async function corpusCommand(flags, subcommand) {
10577
10710
  if (flags.verdict) {
10578
10711
  const caseKey = flags.case || flags['case-key'];
10579
10712
  if (!caseKey) throw new Error('corpus review --verdict requires --case <caseKey>');
10580
- data = await post({ command: 'review', corpus: corpusName, taskId, caseKey, verdict: flags.verdict, score: flags.score, note: flags.note });
10713
+ data = await post({ command: 'review', corpus: corpusName, taskId, caseKey, verdict: flags.verdict, score: flags.score, note: flags.note, repeatIndex: flags.repeat });
10581
10714
  } else {
10582
10715
  data = await post({ command: 'review', corpus: corpusName, taskId, size: flags.sample || flags.size });
10583
10716
  }
@@ -18179,7 +18312,7 @@ function printComponentsHelp(subcommand) {
18179
18312
  }
18180
18313
 
18181
18314
  function printTestHelp() {
18182
- console.log('Usage: remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none] [--json]');
18315
+ console.log('Usage: remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none] [--label TEXT] [--param name=value ...] [--repeat N] [--parallel N] [--json]');
18183
18316
  console.log(' remits-cli test status --task-id <taskId> [--base-url URL] [--account-id ID] [--branch stagingScope] [--data-mode test|prod] [--json]');
18184
18317
  console.log(' remits-cli test runs [--test <id|name>] [--limit 20] [--compare] [--json] # durable run history');
18185
18318
  console.log(' remits-cli test compare --base <taskId> --head <taskId> [--json] # per-case outcome/cost deltas');
@@ -18210,13 +18343,14 @@ function printTestHelp() {
18210
18343
  function printCorpusHelp() {
18211
18344
  console.log('Usage: remits-cli corpus import --manifest corpus-manifest.json [--corpus <name>] [--as-account ID] [--data-mode test|prod --confirm-prod] [--json]');
18212
18345
  console.log(' remits-cli corpus cases --corpus <name> [--split dev] [--tag name] [--key caseKey] [--include-values] [--include-retired] [--json]');
18213
- console.log(' remits-cli corpus results --corpus <name> [--case <caseKey>] [--task-id <taskId>] [--limit 50] [--json]');
18214
- console.log(' remits-cli corpus runs --corpus <name> [--limit 20] [--json]');
18215
- console.log(' remits-cli corpus compare --corpus <name> --base <taskId> --head <taskId> [--json]');
18346
+ console.log(' remits-cli corpus results --corpus <name> [--case <caseKey>] [--task-id <taskId|groupId>] [--label L] [--split S] [--tag T] [--limit 50] [--json]');
18347
+ console.log(' remits-cli corpus runs --corpus <name> [--label L] [--split S] [--tag T] [--limit 20] [--json]');
18348
+ console.log(' remits-cli corpus compare --corpus <name> --base <taskId> --head <taskId> [--split S] [--tag T] [--json]');
18216
18349
  console.log(' remits-cli corpus consistency --corpus <name> --case <caseKey> [--limit 20] [--json]');
18217
18350
  console.log(' remits-cli corpus retire --corpus <name> --case <caseKey> [--case ...] [--restore] [--json]');
18218
- console.log(' remits-cli corpus gate --corpus <name> [--base <taskId>] [--head <taskId>] [--tolerance 0.02] [--score answer] [--lower-is-better <name>] [--allow-mode-change] [--json]');
18219
- console.log(' remits-cli corpus review --corpus <name> --task-id <taskId> [--sample 5] | --case <caseKey> --verdict agree|disagree [--score n] [--note "..."]');
18351
+ console.log(' remits-cli corpus gate --corpus <name> [--base <taskId> | --base-label L] [--head <taskId> | --head-label L] [--split holdout] [--tag T]');
18352
+ console.log(' [--score answer --score passRate] [--tolerance 0.02] [--lower-is-better <name>] [--min-cases N] [--noise-sigma 2] [--allow-mode-change] [--json]');
18353
+ console.log(' remits-cli corpus review --corpus <name> --task-id <taskId> [--sample 5] | --case <caseKey> [--repeat N] --verdict agree|disagree [--score n] [--note "..."]');
18220
18354
  console.log('');
18221
18355
  console.log('A corpus is a named, durable set of cases an evaluation-style Test runs again and again (front stage:');
18222
18356
  console.log('corpus(name)). Cases live in a platform table owned by one account and visible to the accounts beneath');
@@ -18477,7 +18611,7 @@ async function main() {
18477
18611
  console.log(' remits-cli components branch <name> --subscribe <accountId> [--dry-run] [--confirm-primary-edge]');
18478
18612
  console.log(' remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk');
18479
18613
  console.log(' remits-cli components branch <name> --retire [--force] # delete the branch\'s overlays');
18480
- console.log(' remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none] [--json]');
18614
+ console.log(' remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none] [--label TEXT] [--param name=value ...] [--repeat N] [--parallel N] [--json]');
18481
18615
  console.log(' remits-cli test runs [--test <id|name>] [--compare] # durable run history; compare latest two');
18482
18616
  console.log(' remits-cli test compare --base <taskId> --head <taskId>');
18483
18617
  console.log(' remits-cli corpus import --manifest corpus-manifest.json # seed a test corpus: cases + artifacts');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remits/remits-cli",
3
- "version": "0.1.157",
3
+ "version": "0.1.158",
4
4
  "description": "Local CLI for auth, component sync, and live test execution against Remits",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -46,6 +46,7 @@ reference below, load it before you act, not after something surprises you.
46
46
  | work from a non-trunk checkout, or promote a branch | `branch-variants.md` | what a variant is, which world your checkout resolves, `--as-account` vs `--variant-branch`, tombstones, the promotion loop and its phases, subscribe/retire |
47
47
  | touch a support ticket | `support-tickets.md` | the ticket record and its verbs, routed vs claimed vs owned, autonomous workers, the repository edit lease, ask/answer/resume, delivery |
48
48
  | register this session as an agent, or ask why a routed ticket never started | `agent-sessions.md` | `serve` vs `register`, what registration does, worker spawning per agent kind, routing order, the control center, multi-session auth |
49
+ | tune, improve or "fine-tune" a workflow with evals, or compare two variants of one | `development-loop.md` → *Evaluation runs* | `--label`/`--param`/`--repeat`/`--parallel`, the baseline → candidate → gate loop, the holdout rule; the full playbook is `guides/test-components.md` → *Tuning A Workflow With Evals* |
49
50
  | build or evaluate cited Q&A, review learnings, or investigate answer quality | `ai-quality.md` | front-stage guide routing, interaction/session pivots, baseline vs proposal measurements, human checks and explicit corpus gates |
50
51
  | investigate live behavior | `investigation.md` | which tool reads which record, correlation keys, reading a record's `content`, HTTP audits, AI activity, node/`localMode`, the production support flows |
51
52
  | call any `mcp_*` tool | `tool-reference.md` | every tool's parameters, semantics, and traps — read the tool's entry before building its input |
@@ -61,14 +61,20 @@ search written just for the evaluator may measure something the answering Agent
61
61
  2. Make the source/prompt change, or enable `includeProposed` in the Test's Agent/retrieval path.
62
62
  Preview includes all relevant proposals in scope, not one selected id. Keep the proposal set
63
63
  stable. Learning records are shared across data lanes; a workspace does not isolate them.
64
- 3. Re-stage and run the candidate under an explicit `withAiBudget` in the Test. Retain both task ids,
65
- per-case `scores: [retrieval: ..., answer: ...]`, grader results and retrieval diagnostics.
64
+ 3. Re-stage and run the candidate under an explicit `withAiBudget` in the Test, with the same flags as
65
+ the baseline and a different `--label`. Keep per-case `scores: [retrieval: ..., answer: ...]`, grader
66
+ results and retrieval diagnostics.
66
67
 
67
68
  ```bash
68
69
  remits-cli components stage --workset --account-id 1 --base-url http://localhost:8080 --data-mode test
69
- remits-cli test run --test "Support Answer Evaluation" --account-id 1 --base-url http://localhost:8080 --data-mode test --json
70
+ remits-cli test run --test "Support Answer Evaluation" --label baseline --repeat 3 --parallel 2 --account-id 1 --base-url http://localhost:8080 --data-mode test --json
71
+ # make the change (or pass it with --param name=value), re-stage, then:
72
+ remits-cli test run --test "Support Answer Evaluation" --label candidate --repeat 3 --parallel 2 --account-id 1 --base-url http://localhost:8080 --data-mode test --json
70
73
  ```
71
74
 
75
+ `--repeat 3` runs each question three times. Live answers and grades vary from run to run, and the repeats are
76
+ what give each score its `±` noise estimate. `--parallel 2` runs the cases in two shards under one `grp-…` id.
77
+
72
78
  The suite name and account above are examples: create/use your application's suite and resolved account.
73
79
  Repeat after the candidate edit and stage. `--test` selects a suite; `--names` selects exact case names
74
80
  (or a `|`-separated list), not substrings or a regex. Check `total` and outcomes; a zero-case run proves
@@ -85,19 +91,23 @@ verify unreleased platform changes, even with the same account and test lane.
85
91
  With the same explicit host/account/data-mode options as the runs:
86
92
 
87
93
  ```bash
94
+ remits-cli corpus runs --corpus support-golden --limit 2 # each run's id (grp-...), label, scores ± noise
88
95
  remits-cli corpus compare --corpus support-golden --base BASE_TASK_ID --head CANDIDATE_TASK_ID
89
96
  remits-cli corpus review --corpus support-golden --task-id CANDIDATE_TASK_ID --sample 5
90
97
  remits-cli corpus review --corpus support-golden --task-id CANDIDATE_TASK_ID --case CASE_KEY --verdict agree --note "Checked against the source"
91
- remits-cli corpus gate --corpus support-golden --base BASE_TASK_ID --head CANDIDATE_TASK_ID --score retrieval --score answer
98
+ remits-cli corpus gate --corpus support-golden --base-label baseline --head-label candidate --split holdout --score retrieval --score answer --noise-sigma 2
92
99
  remits-cli evidence
93
100
  ```
94
101
 
95
- Replace uppercase ids with the returned task/case ids. Each repeated `--score` is a required metric;
96
- `--scores retrieval,answer` is equivalent. Pin baseline and candidate instead of relying on latest runs.
102
+ Replace uppercase ids with the ids `corpus runs` prints. A parallel run's `grp-…` id is accepted wherever a task
103
+ id is. The gate can select runs by label instead of id. Run it on `holdout`, the split you did not tune against. Each repeated `--score` is a required metric;
104
+ `--scores retrieval,answer` is equivalent. Name the baseline and candidate by id or by label; never rely on the gate's "latest two runs" default.
97
105
  The gate exits nonzero for regressions, lost/absent measurements or an unaccepted mocked/live mode
98
106
  change. It records `corpus_gate` evidence. Inspect measured case counts and excluded changed expected
99
107
  answers; also assess the application's minimum accuracy and the human spot checks. A non-regression
100
- gate alone does not establish an absolute quality target or statistical significance.
108
+ gate does not establish an absolute quality target. With `--noise-sigma 2` (both runs launched with `--repeat`),
109
+ a move only counts as a regression when it is larger than the run-to-run noise. Without repeats, the gate's notes
110
+ say that a small difference may be noise.
101
111
 
102
112
  Stop a release workflow on a failing gate. Approval and component sync do not automatically invoke it.
103
113
  Human review preserves the model grade; it does not replace it. Approve/reject through the application's
@@ -259,18 +259,18 @@ remits-cli components branch <name> --copy-to <newBranch> [--dry-run] [--force]
259
259
  remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>] [--dry-run] [--confirm-primary-edge] # make an account resolve this branch
260
260
  remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk
261
261
  remits-cli components branch <name> --retire [--force] # delete the branch's overlays
262
- remits-cli test run --test <id|name> [--branch <stagingScope>] [--names "a|b"] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account <ID>] [--variant-branch <name|none>] [--json]
263
- remits-cli test status --task-id <taskId> [--branch <stagingScope>] [--data-mode test|prod] [--json] # falls back to the DURABLE run record once the live status expires
262
+ remits-cli test run --test <id|name> [--branch <stagingScope>] [--names "a|b"] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account <ID>] [--variant-branch <name|none>] [--label <text>] [--param name=value ...] [--repeat N] [--parallel N] [--json]
263
+ remits-cli test status --task-id <taskId|groupId> [--branch <stagingScope>] [--data-mode test|prod] [--json] # falls back to the DURABLE run record once the live status expires
264
264
  remits-cli test runs [--test <id|name>] [--limit 20] [--compare] [--all-lanes] [--as-account <ID>] [--json] # durable run history: pass counts, live AI cost, lane, content hash
265
265
  remits-cli test compare --base <taskId> --head <taskId> [--json] # per-case improved / regressed / changed, cost and world deltas
266
266
  remits-cli corpus import --manifest corpus-manifest.json [--corpus <name>] [--as-account <ID>] [--data-mode test|prod --confirm-prod] [--json] # seed an evaluation corpus: cases + immutable artifacts; idempotent by caseKey
267
267
  remits-cli corpus cases --corpus <name> [--split S] [--tag T|--tags T,U] [--key K|--keys K,L] [--include-values] [--include-retired] [--limit N] [--json]
268
- remits-cli corpus runs --corpus <name> [--limit 20] [--json] # runs that measured the corpus: outcomes, AI mode (live|MOCKED|none), cost, world
269
- remits-cli corpus results --corpus <name> [--case <caseKey>] [--task-id <taskId>] [--json]
270
- remits-cli corpus compare --corpus <name> --base <taskId> --head <taskId> [--json] # per-case direction, metric deltas, expectedChanged
271
- remits-cli corpus gate --corpus <name> --base <taskId> --head <taskId> [--score retrieval --score answer] [--lower-is-better <name>] [--tolerance 0.02] [--allow-mode-change] [--json]
268
+ remits-cli corpus runs --corpus <name> [--label L] [--split S] [--tag T] [--limit 20] [--json] # runs that measured the corpus: label, repeats, shards, pass rate, scores ± noise, per split, AI mode (live|MOCKED|none), cost, world
269
+ remits-cli corpus results --corpus <name> [--case <caseKey>] [--task-id <taskId|groupId>] [--label L] [--split S] [--tag T] [--json]
270
+ remits-cli corpus compare --corpus <name> --base <taskId> --head <taskId> [--split S] [--tag T] [--json] # per-case direction, pass rate, metric deltas, expectedChanged
271
+ remits-cli corpus gate --corpus <name> [--base <taskId> | --base-label L] [--head <taskId> | --head-label L] [--split holdout] [--tag T] [--score retrieval --score passRate] [--lower-is-better <name>] [--tolerance 0.02] [--min-cases N] [--noise-sigma 2] [--allow-mode-change] [--json]
272
272
  remits-cli corpus review --corpus <name> --task-id <taskId> [--sample 5] [--json]
273
- remits-cli corpus review --corpus <name> --task-id <taskId> --case <key> --verdict agree|disagree [--score n] [--note "..."]
273
+ remits-cli corpus review --corpus <name> --task-id <taskId> --case <key> [--repeat N] --verdict agree|disagree [--score n] [--note "..."]
274
274
  remits-cli corpus consistency --corpus <name> --case <caseKey> [--json] # did the case produce the same metrics every run?
275
275
  remits-cli corpus retire --corpus <name> --case <caseKey> [--case ...] [--restore] [--json] # drop a case from future runs; past measurements stay readable
276
276
  ```
@@ -328,8 +328,12 @@ For the baseline → proposed learning → human review workflow, see `ai-qualit
328
328
  `--score retrieval --score answer` requires both metrics (`--scores retrieval,answer` is equivalent).
329
329
  `interactions()` and `memories()` are component DSL APIs, not top-level CLI subcommands.
330
330
 
331
- `corpus gate` exits nonzero for a score regression, missing measurements, or an unaccepted AI-mode
332
- change, and records `corpus_gate` evidence. Run it explicitly before sync or approving a learning;
331
+ `corpus gate` exits nonzero for a score regression, missing measurements, too few shared cases
332
+ (`--min-cases`), a missing noise estimate (`--noise-sigma` without `--repeat` on both runs), or an unaccepted
333
+ AI-mode change, and records `corpus_gate` evidence. `passRate`, `liveAiCost`, `liveAiCalls` and `durationMs` are
334
+ built-in scores every case carries; the last three are lower-is-better without `--lower-is-better`.
335
+ `--base-label`/`--head-label` select the latest run with that `test run --label`. Every corpus command accepts a
336
+ parallel run's `grp-…` id wherever it accepts a task id. Run it explicitly before sync or approving a learning;
333
337
  neither operation invokes it automatically. Prefer explicit run ids and check their world.
334
338
  `corpus review` samples graded cases or records a human spot check; it preserves the model grade.
335
339
 
@@ -20,6 +20,7 @@
20
20
  - [Stage your workset, not the whole repo](#stage-your-workset-not-the-whole-repo)
21
21
  - [Three numbers, three questions](#three-numbers-three-questions)
22
22
  - [Step 4: Verify the Change](#step-4-verify-the-change)
23
+ - [Evaluation runs](#evaluation-runs)
23
24
  - [Many failures are usually few causes](#many-failures-are-usually-few-causes)
24
25
  - [Step 5: Iterate If Needed](#step-5-iterate-if-needed)
25
26
  - [Step 6: Update Documentation](#step-6-update-documentation)
@@ -430,11 +431,38 @@ about a different world.
430
431
 
431
432
  The platform launch is asynchronous. By default `remits-cli test run` waits for its task to finish, and any
432
433
  cases selected by `--names "a|b"` execute sequentially inside that suite task. If several slow cases are
433
- independent, stage once, then start separate `test run --names "<case>" --wait false` commands from the same
434
- lane and keep the task ids they print. Complete each proof with
435
- `remits-cli test status --task-id <id>`; that terminal status read records and attaches the final `test_run`
436
- evidence. Do not parallelize cases that mutate the same fixture, rely on shared suite setup state, or make
437
- undeclared live AI/provider calls.
434
+ independent, use `--parallel N`. It splits the selected cases across N shards that run side by side, and the run
435
+ keeps ONE id (`grp-…`) for `test status` and every corpus command. An instance runs only a few suites at once
436
+ (two by default), so shards beyond that wait for a slot. Ask for 2–4. Starting separate
437
+ `test run --names "<case>" --wait false` commands also works for one-off proofs, but every such run is a separate
438
+ run: a corpus cannot compare or gate them as one. Do not parallelize cases that mutate the same fixture, rely on
439
+ shared suite setup state, or make undeclared live AI/provider calls.
440
+
441
+ ##### Evaluation runs
442
+
443
+ When the job is to improve a workflow ("tune it", "make it more accurate", "compare these two prompts"), the unit
444
+ of evidence is a measured corpus run, not a green suite. Follow this loop:
445
+
446
+ ```bash
447
+ remits-cli test run --test "<eval suite>" --label baseline --repeat 3 --parallel 4 # live, inside withAiBudget
448
+ remits-cli corpus runs --corpus <name> --split dev # pass rate, scores ± noise
449
+ remits-cli corpus results --corpus <name> --label baseline --split dev # failing cases -> trace ids
450
+ # change ONE thing (or pass the variant with --param name=value), re-stage, then:
451
+ remits-cli test run --test "<eval suite>" --label candidate --repeat 3 --parallel 4
452
+ remits-cli corpus runs --corpus <name> --limit 2 # the two runs' grp-ids
453
+ remits-cli corpus compare --corpus <name> --base <baseline grp-id> --head <candidate grp-id>
454
+ remits-cli corpus gate --corpus <name> --base-label baseline --head-label candidate --split holdout --noise-sigma 2
455
+ ```
456
+
457
+ Rules:
458
+
459
+ - Tune against `dev`, and gate on `holdout` only once you are done.
460
+ - Keep the lane, the AI mode and the corpus identical between the two runs.
461
+ - Read a failing case's AI session before you change a prompt.
462
+ - Report the gate's numbers, its `±` and its notes, not "it passed".
463
+
464
+ The full playbook, `runParams()` and `compareFields(...)` are in `guides/test-components.md` →
465
+ *Tuning A Workflow With Evals*.
438
466
 
439
467
  If cases fail, read the printed summary and pivots first: each case has an `outcome`
440
468
  (`failed`, `error`, `budget_exceeded`, `provider_unavailable`, …) with its reason, timing, trace id, AI usage