@remits/remits-cli 0.1.156 → 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
- - L11103 Service Dashboard And WebSocket Listener
17
- - L13602 Agent And Ticket Workflows
18
- - L17778 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']);
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
 
@@ -10343,6 +10457,21 @@ function corpusAiLabel(row = {}) {
10343
10457
  return 'no AI';
10344
10458
  }
10345
10459
 
10460
+ function corpusScoreSummary(scores = {}) {
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 : '') +
10463
+ (scores[k] && scores[k].cases !== undefined ? '/' + scores[k].cases : '')).join(', ');
10464
+ }
10465
+
10466
+ function corpusScoreDeltas(scores = {}) {
10467
+ return Object.keys(scores).map((k) => {
10468
+ const s = scores[k] || {};
10469
+ const delta = s.delta !== undefined && s.delta !== null ? ' (' + (Number(s.delta) >= 0 ? '+' : '') + s.delta + ')' : '';
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' : '');
10472
+ }).join(', ');
10473
+ }
10474
+
10346
10475
  function corpusLines(sub, data = {}) {
10347
10476
  const lines = ['Corpus: ' + (data.corpus || '?') + ' (owner account ' + (data.ownerAccountId || '?') + ')'];
10348
10477
  if (sub === 'cases') {
@@ -10351,13 +10480,25 @@ function corpusLines(sub, data = {}) {
10351
10480
  cases.forEach((c) => lines.push('- ' + c.caseKey + (c.split ? ' [' + c.split + ']' : '') + (c.tags && c.tags.length ? ' tags ' + c.tags.join(',') : '') +
10352
10481
  ' expected ' + (c.hasExpected ? (c.expectedStatus || 'present') : 'none') + ' artifacts ' + (c.artifacts || []).map((a) => a.name + ':' + a.bytes + 'b').join(' ')));
10353
10482
  } else if (sub === 'results') {
10354
- (data.results || []).forEach((r) => lines.push('- ' + r.caseKey + ' ' + (r.outcome || '?') + (r.score !== undefined ? ' score ' + r.score : '') +
10355
- ' 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) : '') +
10356
10485
  ' metrics ' + String(r.metricsHash || '').slice(0, 10) + ' ' + formatTime(r.recordedAt)));
10357
10486
  } else if (sub === 'runs') {
10358
- (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 : '') +
10359
10490
  ' ' + corpusAiLabel(r) + ' ' + (r.dataMode || '?') + ' ' + (r.branchName || '?') + (r.workspace ? ' [ws:' + r.workspace + ']' : '') +
10360
10491
  (r.laneContentHash ? ' lane ' + String(r.laneContentHash).slice(0, 12) : '') + ' ' + formatTime(r.recordedAt)));
10492
+ (data.runs || []).forEach((r) => {
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(' '));
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('; '));
10499
+ if (r.review) extra.push('graded ' + r.review.graded + ', spot-checked ' + r.review.reviewed + (r.review.agreement !== undefined ? ' (agreement ' + r.review.agreement + ')' : ''));
10500
+ if (extra.length) lines.push(' ' + r.taskId + ': ' + extra.join(' '));
10501
+ });
10361
10502
  if ((data.runs || []).some((r) => r.aiMode === 'mocked')) {
10362
10503
  lines.push('Runs marked AI MOCKED replayed every AI turn from aiMock - they measure the harness, not the model.');
10363
10504
  }
@@ -10366,10 +10507,13 @@ function corpusLines(sub, data = {}) {
10366
10507
  lines.push('Base ' + cmp.baseTaskId + ' -> head ' + cmp.headTaskId + ': ' + Object.keys(cmp.counts || {}).map((k) => k + ' ' + cmp.counts[k]).join(', '));
10367
10508
  lines.push('AI mode: base ' + (cmp.baseAiMode || '?') + ', head ' + (cmp.headAiMode || '?'));
10368
10509
  if (cmp.aiModeChanged) lines.push('WARNING: ' + cmp.aiModeChanged);
10369
- (cmp.cases || []).filter((c) => c.direction !== 'same').forEach((c) => {
10510
+ if (cmp.scores && Object.keys(cmp.scores).length) lines.push('Scores (cases both runs measured): ' + corpusScoreDeltas(cmp.scores));
10511
+ (cmp.cases || []).filter((c) => c.direction !== 'same' || c.changedScores).forEach((c) => {
10370
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 : '?') : '') +
10371
10514
  (c.expectedChanged ? ' (EXPECTED ANSWER CHANGED between runs)' : ''));
10372
10515
  (c.changedMetrics || []).slice(0, 8).forEach((m) => lines.push(' ' + m.metric + ': ' + JSON.stringify(m.base) + ' -> ' + JSON.stringify(m.head)));
10516
+ Object.keys(c.changedScores || {}).slice(0, 8).forEach((k) => lines.push(' score ' + k + ': ' + c.changedScores[k].base + ' -> ' + c.changedScores[k].head));
10373
10517
  if (c.head && c.head.outcomeReason && c.head.outcome !== 'passed') lines.push(' reason: ' + String(c.head.outcomeReason).slice(0, 300));
10374
10518
  });
10375
10519
  } else if (sub === 'retire') {
@@ -10380,17 +10524,41 @@ function corpusLines(sub, data = {}) {
10380
10524
  if ((data.cases || []).some((c) => c.status === 'retired' || c.status === 'restored')) {
10381
10525
  lines.push('Retired cases stay readable in results and compare; they are just no longer handed to runs. `corpus cases --include-retired` lists them, `corpus retire --restore` puts them back.');
10382
10526
  }
10527
+ } else if (sub === 'gate') {
10528
+ const gate = data.gate || {};
10529
+ lines.push('Gate ' + (gate.baseTaskId || '?') + ' -> ' + (gate.headTaskId || '?') + ': ' + (gate.passed ? 'PASSED' : 'DID NOT PASS') +
10530
+ (gate.tolerance !== undefined ? ' (tolerance ' + gate.tolerance + ')' : '') + (gate.noiseSigma !== undefined ? ' (noise sigma ' + gate.noiseSigma + ')' : '') +
10531
+ (gate.split ? ' split ' + gate.split : ''));
10532
+ if (gate.scores && Object.keys(gate.scores).length) lines.push('Scores: ' + corpusScoreDeltas(gate.scores));
10533
+ (gate.reasons || []).forEach((r) => lines.push('- ' + r));
10534
+ (gate.notes || []).forEach((n) => lines.push('Note: ' + n));
10535
+ if (gate.expectedChanged && gate.expectedChanged.length) lines.push('Left out (expected answer changed between runs): ' + gate.expectedChanged.join(', '));
10536
+ (gate.changedCases || []).slice(0, 15).forEach((c) => lines.push(' ' + c.caseKey + ' ' + c.direction +
10537
+ Object.keys(c.changedScores || {}).map((k) => ' ' + k + ' ' + c.changedScores[k].base + '->' + c.changedScores[k].head).join('')));
10538
+ } else if (sub === 'review') {
10539
+ if (data.review) {
10540
+ lines.push('Recorded: ' + data.review.caseKey + ' in run ' + data.review.taskId + ': ' + data.review.verdict + (data.review.score !== undefined && data.review.score !== null ? ' (score ' + data.review.score + ')' : ''));
10541
+ } else {
10542
+ const sample = data.sample || [];
10543
+ lines.push('Spot-check sample: ' + sample.length + ' graded case(s), unreviewed first');
10544
+ sample.forEach((c) => {
10545
+ const g = c.grade || {};
10546
+ lines.push('- ' + c.caseKey + ' grade ' + (g.verdict || '?') + ' ' + (g.score !== undefined ? g.score : '') + (c.review ? ' reviewed: ' + c.review.verdict : ' NOT REVIEWED'));
10547
+ if (g.reasons) lines.push(' ' + String(g.reasons).slice(0, 300));
10548
+ });
10549
+ if (sample.length) lines.push('Record a check: remits-cli corpus review --corpus ' + (data.corpus || '<name>') + ' --task-id <taskId> --case <caseKey> --verdict agree|disagree [--score n] [--note "..."]');
10550
+ }
10383
10551
  } else if (sub === 'consistency') {
10384
10552
  const con = data.consistency || {};
10385
10553
  lines.push('Case ' + con.caseKey + ': ' + con.runs + ' run(s), ' + (con.stable ? 'STABLE (identical metrics every run)' : (con.variants || []).length + ' distinct metric results') +
10386
10554
  (con.aiModes && con.aiModes.length ? ' AI ' + con.aiModes.join('+') : ''));
10387
10555
  if (con.note) lines.push('WARNING: ' + con.note);
10388
- (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(', ')));
10389
10557
  }
10390
10558
  return lines;
10391
10559
  }
10392
10560
 
10393
- // `corpus import|cases|results|runs|compare|consistency`: test corpora through the platform's own corpus(name).
10561
+ // `corpus import|cases|results|runs|compare|consistency|retire|gate|review`: test corpora through the platform's own corpus(name).
10394
10562
  async function corpusCommand(flags, subcommand) {
10395
10563
  const cwd = process.cwd();
10396
10564
  ensureLocalState(cwd);
@@ -10495,12 +10663,16 @@ async function corpusCommand(flags, subcommand) {
10495
10663
  const keys = parseCorpusFilterValues(flags, ['key', 'keys', 'case', 'case-key']);
10496
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 });
10497
10665
  } else if (sub === 'results') {
10498
- 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 });
10499
10669
  } else if (sub === 'runs') {
10500
- 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 });
10501
10672
  } else if (sub === 'compare') {
10502
10673
  if (!flags.base || !flags.head) throw new Error('corpus compare requires --base <taskId> --head <taskId>');
10503
- 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 });
10504
10676
  } else if (sub === 'consistency') {
10505
10677
  const caseKey = flags.case || flags['case-key'];
10506
10678
  if (!caseKey) throw new Error('corpus consistency requires --case <caseKey>');
@@ -10510,8 +10682,40 @@ async function corpusCommand(flags, subcommand) {
10510
10682
  if (!retireKeys.length) throw new Error('corpus retire requires --case <caseKey> (repeatable) or --keys a,b,c');
10511
10683
  data = await post({ command: 'retire', corpus: corpusName, keys: retireKeys, restore: flagEnabled(flags.restore) });
10512
10684
  if (data && data.success === false) process.exitCode = 1;
10685
+ } else if (sub === 'gate') {
10686
+ const scores = parseCorpusFilterValues(flags, ['score', 'scores']);
10687
+ const lowerIsBetter = parseCorpusFilterValues(flags, ['lower-is-better']);
10688
+ const tags = parseCorpusFilterValues(flags, ['tag', 'tags']);
10689
+ data = await post({ command: 'gate', corpus: corpusName, base: flags.base, head: flags.head, tolerance: flags.tolerance,
10690
+ scores: scores.length ? scores : undefined,
10691
+ lowerIsBetter: lowerIsBetter.length ? lowerIsBetter : undefined,
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'] });
10695
+ const gate = (data && data.gate) || {};
10696
+ if (!gate.passed) process.exitCode = 1;
10697
+ await appendVerificationPacket(api, cwd, session, accountId, flags, {
10698
+ type: 'corpus_gate',
10699
+ success: !!gate.passed,
10700
+ claim: 'Corpus ' + corpusName + ' gate ' + (gate.baseTaskId || '?') + ' -> ' + (gate.headTaskId || '?') + ': ' + (gate.passed ? 'passed' : 'did not pass'),
10701
+ world: Object.assign({}, (data && data.world) || {}, { host: normalizeBaseUrl(baseUrl), branchName, workspace, dataMode }),
10702
+ revision: collectVerificationSource(cwd, flags),
10703
+ evidenceCategories: ['corpus_gate'],
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 }
10706
+ }, { quiet: jsonOutput });
10707
+ } else if (sub === 'review') {
10708
+ const taskId = flags['task-id'];
10709
+ if (!taskId) throw new Error('corpus review requires --task-id <taskId> (and --case <caseKey> --verdict agree|disagree to record a check)');
10710
+ if (flags.verdict) {
10711
+ const caseKey = flags.case || flags['case-key'];
10712
+ if (!caseKey) throw new Error('corpus review --verdict requires --case <caseKey>');
10713
+ data = await post({ command: 'review', corpus: corpusName, taskId, caseKey, verdict: flags.verdict, score: flags.score, note: flags.note, repeatIndex: flags.repeat });
10714
+ } else {
10715
+ data = await post({ command: 'review', corpus: corpusName, taskId, size: flags.sample || flags.size });
10716
+ }
10513
10717
  } else {
10514
- throw new Error('Unknown corpus subcommand: ' + sub + ' (use import, cases, results, runs, compare, consistency or retire)');
10718
+ throw new Error('Unknown corpus subcommand: ' + sub + ' (use import, cases, results, runs, compare, consistency, retire, gate or review)');
10515
10719
  }
10516
10720
  if (jsonOutput) {
10517
10721
  console.log(JSON.stringify(data, null, 2));
@@ -18108,7 +18312,7 @@ function printComponentsHelp(subcommand) {
18108
18312
  }
18109
18313
 
18110
18314
  function printTestHelp() {
18111
- 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]');
18112
18316
  console.log(' remits-cli test status --task-id <taskId> [--base-url URL] [--account-id ID] [--branch stagingScope] [--data-mode test|prod] [--json]');
18113
18317
  console.log(' remits-cli test runs [--test <id|name>] [--limit 20] [--compare] [--json] # durable run history');
18114
18318
  console.log(' remits-cli test compare --base <taskId> --head <taskId> [--json] # per-case outcome/cost deltas');
@@ -18139,11 +18343,14 @@ function printTestHelp() {
18139
18343
  function printCorpusHelp() {
18140
18344
  console.log('Usage: remits-cli corpus import --manifest corpus-manifest.json [--corpus <name>] [--as-account ID] [--data-mode test|prod --confirm-prod] [--json]');
18141
18345
  console.log(' remits-cli corpus cases --corpus <name> [--split dev] [--tag name] [--key caseKey] [--include-values] [--include-retired] [--json]');
18142
- console.log(' remits-cli corpus results --corpus <name> [--case <caseKey>] [--task-id <taskId>] [--limit 50] [--json]');
18143
- console.log(' remits-cli corpus runs --corpus <name> [--limit 20] [--json]');
18144
- 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]');
18145
18349
  console.log(' remits-cli corpus consistency --corpus <name> --case <caseKey> [--limit 20] [--json]');
18146
18350
  console.log(' remits-cli corpus retire --corpus <name> --case <caseKey> [--case ...] [--restore] [--json]');
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 "..."]');
18147
18354
  console.log('');
18148
18355
  console.log('A corpus is a named, durable set of cases an evaluation-style Test runs again and again (front stage:');
18149
18356
  console.log('corpus(name)). Cases live in a platform table owned by one account and visible to the accounts beneath');
@@ -18404,7 +18611,7 @@ async function main() {
18404
18611
  console.log(' remits-cli components branch <name> --subscribe <accountId> [--dry-run] [--confirm-primary-edge]');
18405
18612
  console.log(' remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk');
18406
18613
  console.log(' remits-cli components branch <name> --retire [--force] # delete the branch\'s overlays');
18407
- 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]');
18408
18615
  console.log(' remits-cli test runs [--test <id|name>] [--compare] # durable run history; compare latest two');
18409
18616
  console.log(' remits-cli test compare --base <taskId> --head <taskId>');
18410
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.156",
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,8 @@ 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* |
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 |
49
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 |
50
52
  | call any `mcp_*` tool | `tool-reference.md` | every tool's parameters, semantics, and traps — read the tool's entry before building its input |
51
53
  | run, describe, poll, or interrupt an Action from the CLI | `tool-reference.md` → `mcp_run_action` | Action execution through `remits-cli tool --name mcp_run_action`; there is no separate top-level `remits-cli action` / `run action` wrapper |
@@ -158,12 +160,11 @@ reference named after it.
158
160
  (`component-resolution.md`, `command-reference.md`)
159
161
  - **Host and data mode are two independent decisions.** `--base-url` picks the Remits host,
160
162
  `--data-mode` picks the data segment on it. Neither implies the other. (`command-reference.md`)
161
- - **Localhost being down is not a verification blocker.** `remits-cli` can talk to any reachable Remits
162
- host, including the deployed production host, by passing `--base-url`. If `http://localhost:8080`
163
- refuses a connection, retry the same stage/test/tool/status command against the intended deployed host
164
- with the same account, branch, workspace and data-mode facts; do not stop unless no reachable host or
165
- valid session exists. A deployed host can still run test-lane verification: `--base-url` selects the
166
- server, while `test run` still defaults to `--data-mode test`. (`command-reference.md`)
163
+ - **Use a reachable host that has the implementation you are verifying.** For component work against
164
+ existing capabilities, localhost being down need not block verification: pass the intended deployed
165
+ host with `--base-url`, preserving account, branch, workspace and data-mode facts. For unreleased
166
+ platform capabilities, a deployed old binary is not a substitute; use the updated local/test runtime
167
+ and follow that repository's startup/restart instructions. (`command-reference.md`, `ai-quality.md`)
167
168
  - **`test run` ignores the stored session data lane.** It defaults to `test` even when `whoami` shows
168
169
  the session parked on prod; production Test runs require explicit prod provenance (`--data-mode prod`)
169
170
  or Test source declaring `dataMode 'prod'` / `[dataMode:'prod']`. Check returned `dataModeSource`
@@ -0,0 +1,128 @@
1
+ # Answer Quality And Reviewed Learning
2
+
3
+ Load this when building cited Q&A, investigating a wrong answer, changing retrieval or prompts,
4
+ or measuring a proposed learning before review. Component APIs belong in the account's front-stage
5
+ guides; this reference owns the CLI investigation and measurement workflow.
6
+
7
+ ## Table of Contents
8
+
9
+ - [Find the right guide](#find-the-right-guide)
10
+ - [Investigate one answer](#investigate-one-answer)
11
+ - [Measure a change](#measure-a-change)
12
+ - [Review and gate](#review-and-gate)
13
+ - [Keep the regression](#keep-the-regression)
14
+
15
+ ## Find the right guide
16
+
17
+ | Building or changing | Read in the account repository |
18
+ |---|---|
19
+ | Search Tool → citations → feedback/review page | `guides/features/ai-interactions.md` → *Wiring a question-to-review workflow* |
20
+ | Learning proposals, review, retirement, Agent memory | `guides/features/agent-memory.md` → *Reviewing learnings* |
21
+ | Rewrite, learned wording, rerank, answer spans, retrieval misses | `guides/features/schema-vertex-ai-extension.md` → *Retrieval quality* |
22
+ | Named retrieval/answer scores, rubric grader, baseline/candidate design | `guides/test-components.md` → *Measuring a proposed learning* and *Human Review And Release Gates* |
23
+ | Per-question guidance and timeout recovery | `guides/agent-components.md` → *ai.turnContext* and *Wiring tools to an agent* |
24
+
25
+ Use the built-in interaction and learning stores. `interactions()` and `memories()` are component DSL
26
+ builders, not top-level CLI commands. Call them from an application's Action/review UI or a Test;
27
+ do not bypass their scope and review behavior with SQL. The CLI has `corpus review` and `corpus gate`.
28
+
29
+ ## Investigate one answer
30
+
31
+ Start from the answer's `interactionId`, its sources and feedback. `interactions().trace(id)` gives
32
+ its provider rounds and tool calls; `sessionId` and `traceId` pivot into `mcp_ai_session_search`
33
+ (`investigation.md`, then that tool's entry in `tool-reference.md`). Inspect what the model actually
34
+ received before changing a prompt. For normalization, `unstructured()` also returns `aiSessionId`
35
+ when a model was called, plus `traceId`.
36
+
37
+ Decide what failed before choosing a remedy:
38
+
39
+ - Expected source missing: inspect the actual retrieval and `index_explain` diagnostics in the
40
+ authorized data lane. Test documents are not indexed. Diagnose eligibility/indexing before tuning
41
+ wording; `not_reached` describes a bounded search window, not proof that a document is irrelevant.
42
+ - Source retrieved but answer wrong: inspect the offered passages, per-turn instructions and answer
43
+ trace. A citation is not proof that the source supports the claim.
44
+ - Coverage absent: include a should-decline corpus case; a grounded decline can be the correct result.
45
+ - Terminology mismatch: propose `kind: 'phrasing'` with indexed wording in `details` and a source
46
+ document id in `subjectRef`, then measure it. Feedback alone does not alter retrieval.
47
+
48
+ `quality.rerank.applied: false` is fallback, not successful reranking. `indexSearchMock` bypasses
49
+ rewriting, learned retrieval and ranking; it proves how the caller uses supplied search results.
50
+ Use live-provider evidence for claims about those steps. Ranking charges are outside chat-token budgets.
51
+
52
+ ## Measure a change
53
+
54
+ Use the existing account Test/corpus workflow. Keep durable regression components in the account
55
+ repository, and stage the workset in a dedicated workspace. Establish host, execution account, source
56
+ layer and data lane before running (`components status`). Compare the same cases, expected answers,
57
+ source inputs and model settings. Retrieve and score through the actual application path; a parallel
58
+ search written just for the evaluator may measure something the answering Agent never saw.
59
+
60
+ 1. Capture a baseline with approved memories only. Keep each question in a fresh Agent session.
61
+ 2. Make the source/prompt change, or enable `includeProposed` in the Test's Agent/retrieval path.
62
+ Preview includes all relevant proposals in scope, not one selected id. Keep the proposal set
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, with the same flags as
65
+ the baseline and a different `--label`. Keep per-case `scores: [retrieval: ..., answer: ...]`, grader
66
+ results and retrieval diagnostics.
67
+
68
+ ```bash
69
+ remits-cli components stage --workset --account-id 1 --base-url http://localhost:8080 --data-mode test
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
73
+ ```
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
+
78
+ The suite name and account above are examples: create/use your application's suite and resolved account.
79
+ Repeat after the candidate edit and stage. `--test` selects a suite; `--names` selects exact case names
80
+ (or a `|`-separated list), not substrings or a regex. Check `total` and outcomes; a zero-case run proves
81
+ nothing. `--wait false` only launches; finish with `test status --task-id <id>`.
82
+
83
+ Use deterministic search/AI mocks for protocol regressions and live models against pinned corpus
84
+ artifacts for answer quality. Real index measurements need an explicitly authorized production data
85
+ lane and a read-only application path; changing the lane of a workflow with writes is not a shortcut.
86
+ The intended host must also have the implementation being verified: a deployed old binary cannot
87
+ verify unreleased platform changes, even with the same account and test lane.
88
+
89
+ ## Review and gate
90
+
91
+ With the same explicit host/account/data-mode options as the runs:
92
+
93
+ ```bash
94
+ remits-cli corpus runs --corpus support-golden --limit 2 # each run's id (grp-...), label, scores ± noise
95
+ remits-cli corpus compare --corpus support-golden --base BASE_TASK_ID --head CANDIDATE_TASK_ID
96
+ remits-cli corpus review --corpus support-golden --task-id CANDIDATE_TASK_ID --sample 5
97
+ remits-cli corpus review --corpus support-golden --task-id CANDIDATE_TASK_ID --case CASE_KEY --verdict agree --note "Checked against the source"
98
+ remits-cli corpus gate --corpus support-golden --base-label baseline --head-label candidate --split holdout --score retrieval --score answer --noise-sigma 2
99
+ remits-cli evidence
100
+ ```
101
+
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.
105
+ The gate exits nonzero for regressions, lost/absent measurements or an unaccepted mocked/live mode
106
+ change. It records `corpus_gate` evidence. Inspect measured case counts and excluded changed expected
107
+ answers; also assess the application's minimum accuracy and the human spot checks. A non-regression
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.
111
+
112
+ Stop a release workflow on a failing gate. Approval and component sync do not automatically invoke it.
113
+ Human review preserves the model grade; it does not replace it. Approve/reject through the application's
114
+ `memories()` review path after the decision, and keep the relevant task ids in the review note. Editing
115
+ an approved memory affects later answers immediately; use a proposal when a replacement needs measurement.
116
+
117
+ ## Keep the regression
118
+
119
+ Use `auxiliary: false` for durable Tests and their helper components. Set `regression: true` for suites
120
+ the account expects in its normal regression, including explicitly budgeted provider suites when
121
+ their cost/dependency is intentional. Otherwise leave costly/exploratory measurements opt-in.
122
+ Describe the proof boundary in the sidecar and README: mocked protocol, live provider smoke,
123
+ measured corpus quality, or UI journey. Retire/reject synthetic memories in cleanup.
124
+
125
+ Read `component-integrity.md` before landing. Source files alone do not update the platform's durable
126
+ regression selection: use normal account commit/push/sync when authorized, or stage for local runs.
127
+ Read `evidence` before reporting results; distinguish checked-in/staged source, measured behavior,
128
+ human approval and deployment.
@@ -65,8 +65,10 @@ Do not assume `--data-mode prod` implies the deployed prod host, or that `--data
65
65
  localhost. Likewise, do not assume localhost is required because the current checkout is a back-stage
66
66
  repo or because a previous command used localhost. If host matters, read `~/.remits-cli/sessions.json`,
67
67
  `remits-cli whoami`, or the recent `remits-cli evidence` world blocks, then pass `--base-url` explicitly.
68
- Only call verification blocked after trying the intended reachable host and finding that no authenticated
69
- session or usable network path exists.
68
+ For existing capabilities, try the intended reachable host before declaring a connection blocker.
69
+ For unreleased platform capabilities, also verify that the host has the required implementation.
70
+ Staged component source cannot add missing platform APIs to an older deployed binary; use the updated
71
+ local/test runtime and follow that platform repository's startup/restart instructions.
70
72
 
71
73
  ### Tool Execution Lifecycle
72
74
 
@@ -257,15 +259,18 @@ remits-cli components branch <name> --copy-to <newBranch> [--dry-run] [--force]
257
259
  remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>] [--dry-run] [--confirm-primary-edge] # make an account resolve this branch
258
260
  remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk
259
261
  remits-cli components branch <name> --retire [--force] # delete the branch's overlays
260
- 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]
261
- 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
262
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
263
265
  remits-cli test compare --base <taskId> --head <taskId> [--json] # per-case improved / regressed / changed, cost and world deltas
264
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
265
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]
266
- remits-cli corpus runs --corpus <name> [--limit 20] [--json] # runs that measured the corpus: outcomes, AI mode (live|MOCKED|none), cost, world
267
- remits-cli corpus results --corpus <name> [--case <caseKey>] [--task-id <taskId>] [--json]
268
- remits-cli corpus compare --corpus <name> --base <taskId> --head <taskId> [--json] # per-case direction, metric deltas, expectedChanged
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
+ remits-cli corpus review --corpus <name> --task-id <taskId> [--sample 5] [--json]
273
+ remits-cli corpus review --corpus <name> --task-id <taskId> --case <key> [--repeat N] --verdict agree|disagree [--score n] [--note "..."]
269
274
  remits-cli corpus consistency --corpus <name> --case <caseKey> [--json] # did the case produce the same metrics every run?
270
275
  remits-cli corpus retire --corpus <name> --case <caseKey> [--case ...] [--restore] [--json] # drop a case from future runs; past measurements stay readable
271
276
  ```
@@ -318,6 +323,20 @@ of the same **shape** - same data lane, branch, workspace and case count - and s
318
323
  skipped; it refuses rather than comparing across worlds. `test status --data-mode prod` on a run recorded in
319
324
  the test lane returns the run's real lane and now says that is what happened.
320
325
 
326
+ For the baseline → proposed learning → human review workflow, see `ai-quality.md` and
327
+ `guides/test-components.md` → *Measuring a proposed learning*. `--score` is repeatable;
328
+ `--score retrieval --score answer` requires both metrics (`--scores retrieval,answer` is equivalent).
329
+ `interactions()` and `memories()` are component DSL APIs, not top-level CLI subcommands.
330
+
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;
337
+ neither operation invokes it automatically. Prefer explicit run ids and check their world.
338
+ `corpus review` samples graded cases or records a human spot check; it preserves the model grade.
339
+
321
340
  A run labelled **`AI MOCKED`** replayed every AI turn from `aiMock`: it produces the same outcomes, scores and
322
341
  metrics a measured run would, so read that label before treating green as evidence. `compare` warns when base and
323
342
  head used different AI modes; `consistency` warns before calling a case unstable when its runs mixed modes.
@@ -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)
@@ -68,6 +69,10 @@ The right model is:
68
69
 
69
70
  Never treat "it looks right in prod data inspection" as sufficient proof that a code change is verified.
70
71
 
72
+ For cited answers, retrieval changes or reviewed learnings, load `ai-quality.md`. It connects the
73
+ component workflow to measured baseline/candidate runs, human checks and `corpus gate`; a passing
74
+ mocked Test alone cannot establish improved answer quality.
75
+
71
76
  ## Verification Envelopes
72
77
 
73
78
  For a concrete user workflow, browser-facing change, branch-variant release, support ticket, or any loop
@@ -426,11 +431,38 @@ about a different world.
426
431
 
427
432
  The platform launch is asynchronous. By default `remits-cli test run` waits for its task to finish, and any
428
433
  cases selected by `--names "a|b"` execute sequentially inside that suite task. If several slow cases are
429
- independent, stage once, then start separate `test run --names "<case>" --wait false` commands from the same
430
- lane and keep the task ids they print. Complete each proof with
431
- `remits-cli test status --task-id <id>`; that terminal status read records and attaches the final `test_run`
432
- evidence. Do not parallelize cases that mutate the same fixture, rely on shared suite setup state, or make
433
- 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*.
434
466
 
435
467
  If cases fail, read the printed summary and pivots first: each case has an `outcome`
436
468
  (`failed`, `error`, `budget_exceeded`, `provider_unavailable`, …) with its reason, timing, trace id, AI usage
@@ -57,6 +57,8 @@ different lanes (see `platform-overview.md` → *Test and Production Data Lanes*
57
57
  - **`sessionKey`** — salted user-activity session key. Pivot `mcp_user_activity` `sessions` → `story`;
58
58
  each beat then carries a `traceId` / `threadGroupingId` for trace and log investigation.
59
59
  - **`sessionId`** — pivots into persisted AI activity via `mcp_ai_session_search`.
60
+ - **`interactionId`** — one Agent answer, including feedback and citations. In component code,
61
+ `interactions().get(id)` / `.trace(id)` open it; use its `sessionId` / `traceId` for deeper forensics.
60
62
 
61
63
  ### Reading a record's `content` — persisted context, not a memory dump
62
64
 
@@ -109,7 +111,13 @@ embeddable patterns: `features/http-audits.md` (`mcp_get_guide`).
109
111
 
110
112
  ### AI activity
111
113
 
112
- Every `ai()` call and Agent turn is persisted. Two ways in:
114
+ For a rated or corrected Agent answer, start with `interactions().trace(interactionId)` from an
115
+ Action or Test using the component DSL. `correlation: request_ids` identifies its recorded rounds;
116
+ legacy `time_window` matches may overlap adjacent answers. Empty rounds can mean logging was off or
117
+ telemetry expired. There is no top-level CLI `interactions` command: use your review Action, or pivot
118
+ its session/trace into the tools below. `ai-quality.md` routes the full correction/evaluation workflow.
119
+
120
+ AI request logging provides two deeper ways in:
113
121
 
114
122
  - **`mcp_ai_session_search`** — search groupings and open their detail. A grouping spans every session
115
123
  sharing one grouping id: the agent turns plus its guardrail and internal `ai()` calls. Use the