@remits/remits-cli 0.1.127 → 0.1.129

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ remits-cli components stage # FULL SNAPSHOT of the repo into t
28
28
  remits-cli components status
29
29
  remits-cli components clear
30
30
  remits-cli test run --test 45
31
- remits-cli test run --test "My New Test" --names "test case 1,test case 2"
31
+ remits-cli test run --test "My New Test" --names "test case 1|test case 2"
32
32
  git add -A
33
33
  git commit -m "sync passing changes"
34
34
  git push
@@ -98,7 +98,8 @@ remits-cli install --skills --overwrite true
98
98
  - Branch defaults to the current local git branch.
99
99
  - Data mode defaults to `test`. For `remits-cli test run`, that default is now enforced even if the most-recent authenticated session for the account is `prod`; a production test run therefore requires an explicit `--data-mode prod` on the command line. Use `remits-cli data-mode set prod` only for production investigation.
100
100
  - If the same account is authenticated against more than one host and you omit `--base-url`, the CLI auto-resolves the best matching session and now prints the resolved host. Pass `--base-url` explicitly whenever the target host matters.
101
- - Avoid commas in individual test names. The `--names` filter is comma-delimited, so a single test case whose name contains commas cannot be targeted cleanly through `remits-cli test run --names ...`.
101
+ - `--names` is `|`-delimited and may be repeated. A comma still splits a single value for compatibility,
102
+ so a case name containing a comma should be passed with `|` or by repeating `--names`.
102
103
  - Nested help is available before required-argument validation, including `remits-cli test run --help`, `remits-cli components sync --help`, and `remits-cli tool --help`.
103
104
  - `components sync --safe` is the recommended agent path on a variant branch. It expands to `--summary --changed-only --fail-on-errors --fail-on-removed`, resolves `--changed-since` from the branch's merge base with trunk when you did not name one (local refs only; it never runs an implicit `git fetch`), and prints the planned writes before mutating unless `--yes` is passed. On trunk there is no plan to gate, so it states what a trunk reconcile does and requires `--yes`.
104
105
  - `components sync` has fail-closed safety gates for unattended/agent use. Each exits non-zero instead of printing a wall of JSON: `--changed-only` (fail unless every planned write is a component this checkout edited), `--names-only` (print only `BUCKET type:id name` lines), `--fail-on-removed`, `--fail-on-errors`, and `--expected-removed <type:id>` (repeatable or comma-delimited; implies `--fail-on-removed`, so any removal you did not name fails). `--changed-only` also fails closed when the checkout is not a git working tree, because "git could not answer" must never be read as "nothing changed".
package/index.js CHANGED
@@ -6,16 +6,16 @@
6
6
  - L22 Runtime Bootstrap And Shared State
7
7
  - L116 Sessions, Account Resolution, And Production Guards
8
8
  - L595 Local State, Workspaces, And Verification Evidence
9
- - L1296 Account Repos, Guide Sync, And Platform Repo
10
- - L1954 Component Discovery And HTTP Logging
11
- - L2444 Skill Delivery And TOC Resolution
12
- - L2754 Auth And Component Staging
13
- - L3352 Component Summaries, Status, And Sync Gates
14
- - L5107 Branches, Promotion, Commit, And Test Runs
15
- - L6231 Tokens, Tools, Verification, And Config
16
- - L7141 Service Dashboard And WebSocket Listener
17
- - L9236 Agent And Ticket Workflows
18
- - L12294 Help, Auto Update, And Command Dispatch
9
+ - L1448 Account Repos, Guide Sync, And Platform Repo
10
+ - L2106 Component Discovery And HTTP Logging
11
+ - L2598 Skill Delivery And TOC Resolution
12
+ - L2908 Auth And Component Staging
13
+ - L3506 Component Summaries, Status, And Sync Gates
14
+ - L5457 Branches, Promotion, Commit, And Test Runs
15
+ - L6682 Tokens, Tools, Verification, And Config
16
+ - L7598 Service Dashboard And WebSocket Listener
17
+ - L9693 Agent And Ticket Workflows
18
+ - L12751 Help, Auto Update, And Command Dispatch
19
19
  */
20
20
 
21
21
  /*
@@ -821,6 +821,22 @@ function fileEvidence(pathname) {
821
821
  };
822
822
  }
823
823
 
824
+ const LARGE_TOOL_RESPONSE_BYTES = 200 * 1024;
825
+
826
+ function formatToolResponseFile(pathname) {
827
+ const evidence = fileEvidence(pathname);
828
+ if (!evidence.bytes) return String(pathname || '');
829
+ return pathname + ' (' + evidence.bytes + ' bytes, sha256 ' + evidence.sha256.slice(0, 12) + ')';
830
+ }
831
+
832
+ function printToolResponseFileLine(label, pathname) {
833
+ console.log(label + ':', formatToolResponseFile(pathname));
834
+ const evidence = fileEvidence(pathname);
835
+ if (evidence.bytes && evidence.bytes >= LARGE_TOOL_RESPONSE_BYTES) {
836
+ console.log('Large response: inspect selectively with jq or targeted offset reads instead of opening the whole file.');
837
+ }
838
+ }
839
+
824
840
  function boundedToolExcerpt(data = {}) {
825
841
  const result = data.result && typeof data.result === 'object' ? data.result : {};
826
842
  const candidates = [
@@ -982,6 +998,35 @@ function readLocalVerificationEnvelope(cwd, envelopeId) {
982
998
  }
983
999
  }
984
1000
 
1001
+ function readLocalVerificationPackets(cwd, envelopeId, filters = {}) {
1002
+ const paths = verificationPaths(cwd, envelopeId);
1003
+ const packets = [];
1004
+ if (fs.existsSync(paths.packetsDir)) {
1005
+ fs.readdirSync(paths.packetsDir)
1006
+ .filter((name) => name.endsWith('.json'))
1007
+ .forEach((name) => {
1008
+ try {
1009
+ const parsed = JSON.parse(fs.readFileSync(path.join(paths.packetsDir, name), 'utf8'));
1010
+ if (parsed && typeof parsed === 'object') packets.push(parsed);
1011
+ } catch (_) {
1012
+ // Ignore one malformed local packet; the rest of the envelope remains useful.
1013
+ }
1014
+ });
1015
+ }
1016
+ if (!packets.length) {
1017
+ const envelope = readLocalVerificationEnvelope(cwd, envelopeId);
1018
+ if (envelope && Array.isArray(envelope.packets)) packets.push(...envelope.packets);
1019
+ }
1020
+ const type = filters.type || filters.packetType;
1021
+ const suite = filters.suite || filters.testName;
1022
+ const max = parsePositiveInt(filters.max, 50);
1023
+ const filtered = packets
1024
+ .filter((packet) => !type || packet.type === type)
1025
+ .filter((packet) => !suite || testRunSuiteNameFromPacket(packet) === String(suite))
1026
+ .sort((a, b) => Number(a.createdAt || 0) - Number(b.createdAt || 0));
1027
+ return filtered.slice(Math.max(0, filtered.length - max));
1028
+ }
1029
+
985
1030
  function writeLocalVerificationPacket(cwd, envelopeId, packet) {
986
1031
  if (!envelopeId || !packet) return null;
987
1032
  const paths = verificationPaths(cwd, envelopeId);
@@ -1062,6 +1107,43 @@ async function postVerificationCommand(api, cwd, session, accountId, command, pa
1062
1107
  return response;
1063
1108
  }
1064
1109
 
1110
+ // The repo/session account a packet was recorded from, stated explicitly in its world. `world.accountId` is the
1111
+ // account the command EXECUTED as, so an --as-account run carried the subscriber there and a manifest's
1112
+ // repoAccountId could never match it. Only fills a missing value; a packet that already knows keeps its own.
1113
+ function stampPacketRepoAccount(packet, accountId) {
1114
+ if (!packet || accountId === undefined || accountId === null || accountId === '') return packet;
1115
+ const repoAccountId = Number(accountId);
1116
+ if (!Number.isFinite(repoAccountId)) return packet;
1117
+ if (!packet.world || typeof packet.world !== 'object') packet.world = {};
1118
+ if (packet.world.repoAccountId === undefined || packet.world.repoAccountId === null) {
1119
+ packet.world.repoAccountId = repoAccountId;
1120
+ }
1121
+ return packet;
1122
+ }
1123
+
1124
+ // A sync packet is evidence of WHAT a sync planned or wrote, not a second copy of the preview. The per-field
1125
+ // trunk/branch text a dry run attaches for the admin diff panel is up to 400 KB per sync, and storing it on
1126
+ // every packet made one busy envelope 9.4 MB — slow enough that `verify show` timed out. Field NAMES
1127
+ // (`changedFields`) stay; the full plan is always one `components sync --dry-run` away.
1128
+ function syncEvidencePayload(sync) {
1129
+ if (!sync || typeof sync !== 'object') return sync;
1130
+ const results = sync.syncResults;
1131
+ if (!results || typeof results !== 'object') return sync;
1132
+ const trimmedResults = {};
1133
+ Object.keys(results).forEach((bucket) => {
1134
+ const entries = results[bucket];
1135
+ trimmedResults[bucket] = Array.isArray(entries)
1136
+ ? entries.map((entry) => {
1137
+ if (!entry || typeof entry !== 'object' || !('fields' in entry)) return entry;
1138
+ const copy = Object.assign({}, entry);
1139
+ delete copy.fields;
1140
+ return copy;
1141
+ })
1142
+ : entries;
1143
+ });
1144
+ return Object.assign({}, sync, { syncResults: trimmedResults });
1145
+ }
1146
+
1065
1147
  async function appendVerificationPacket(api, cwd, session, accountId, flags, packet, options = {}) {
1066
1148
  const context = activeVerificationContext(cwd, flags, session, accountId);
1067
1149
  const envelopeId = verificationEnvelopeIdForCommand(cwd, flags, context);
@@ -1072,6 +1154,7 @@ async function appendVerificationPacket(api, cwd, session, accountId, flags, pac
1072
1154
  success: true
1073
1155
  }, packet || {});
1074
1156
  finalPacket.envelopeId = envelopeId;
1157
+ stampPacketRepoAccount(finalPacket, accountId);
1075
1158
  writeLocalVerificationPacket(cwd, envelopeId, finalPacket);
1076
1159
  try {
1077
1160
  const response = await postVerificationCommand(api, cwd, session, accountId, 'packet', {
@@ -1103,11 +1186,13 @@ function printVerificationSummaryLine(env) {
1103
1186
  const failures = env.currentFailureCount ? ' currentFailures=' + env.currentFailureCount : (env.failedCount ? ' failed=' + env.failedCount : '');
1104
1187
  const required = ' required=' + (env.satisfiedCount || 0) + '/' + (env.requiredCount || 0);
1105
1188
  const noManifest = env.noRequiredEvidence ? ' no-manifest' : '';
1189
+ const stale = env.staleCount ? ' stale=' + env.staleCount : '';
1190
+ const changed = env.requirementsChangedAfterEvidence ? ' requirements-changed' : '';
1106
1191
  const lane = [env.branchName || 'unknown-branch', env.workspace ? 'ws:' + env.workspace : 'shared', env.dataMode || null, env.sourceLayer || null]
1107
1192
  .filter(Boolean).join(' | ');
1108
- console.log('- ' + (env.envelopeId || '(no id)') + ' ' + (env.status || 'unknown') + health + failures + required + noManifest);
1193
+ console.log('- ' + (env.envelopeId || '(no id)') + ' ' + (env.status || 'unknown') + health + failures + required + stale + noManifest + changed);
1109
1194
  if (env.summary) console.log(' ' + env.summary);
1110
- console.log(' ' + lane + ' packets=' + (env.packetCount || 0) + ' updated=' + formatTime(env.updatedAtMs || env.latestPacketAtMs));
1195
+ console.log(' ' + lane + ' packets=' + (env.packetCount || 0) + ' lastPacket=' + formatTime(env.lastPacketAtMs || env.latestPacketAtMs) + ' updated=' + formatTime(env.updatedAtMs || env.latestPacketAtMs));
1111
1196
  }
1112
1197
 
1113
1198
  function printVerificationEnvelope(envelope, fallbackEnvelopeId) {
@@ -1124,6 +1209,12 @@ function printVerificationEnvelope(envelope, fallbackEnvelopeId) {
1124
1209
  if (evaluation.failedCount) {
1125
1210
  console.log('Failed packets:', evaluation.failedCount, 'current failures:', evaluation.currentFailureCount || 0);
1126
1211
  }
1212
+ if (evaluation.requirementsChangedAfterEvidence) {
1213
+ console.log('Requirements changed after evidence was collected.');
1214
+ }
1215
+ if (Array.isArray(evaluation.stale) && evaluation.stale.length) {
1216
+ console.log('Stale packets:', evaluation.stale.length);
1217
+ }
1127
1218
  const packets = Array.isArray(envelope.packets) ? envelope.packets : [];
1128
1219
  console.log('Packets:', packets.length);
1129
1220
  packets.slice(-20).forEach((packet) => {
@@ -1147,6 +1238,56 @@ function printAcceptanceWarnings(envelope) {
1147
1238
  });
1148
1239
  }
1149
1240
 
1241
+ function printEnvelopeWarnings(envelope) {
1242
+ const warnings = envelope && Array.isArray(envelope.warnings) ? envelope.warnings : [];
1243
+ if (!warnings.length) return;
1244
+ console.log('Envelope warnings:');
1245
+ warnings.forEach((warning) => {
1246
+ console.log('- ' + (warning.message || warning.code || JSON.stringify(warning)));
1247
+ if (Array.isArray(warning.envelopes) && warning.envelopes.length) {
1248
+ warning.envelopes.forEach((env) => {
1249
+ console.log(' ' + (env.envelopeId || '(no id)') + (env.status ? ' ' + env.status : ''));
1250
+ });
1251
+ }
1252
+ });
1253
+ }
1254
+
1255
+ function manifestRequiredEvidence(manifest = {}) {
1256
+ const required = [];
1257
+ if (Array.isArray(manifest.requiredEvidence)) required.push(...manifest.requiredEvidence);
1258
+ const journeys = Array.isArray(manifest.journeys) ? manifest.journeys :
1259
+ (Array.isArray(manifest.requestedJourneys) ? manifest.requestedJourneys : []);
1260
+ journeys.forEach((journey) => {
1261
+ if (journey && Array.isArray(journey.requiredEvidence)) required.push(...journey.requiredEvidence);
1262
+ });
1263
+ return required;
1264
+ }
1265
+
1266
+ async function readVerificationEnvelopeForDiagnostics(api, cwd, session, accountId, envelopeId) {
1267
+ if (!envelopeId) return null;
1268
+ try {
1269
+ const response = await postVerificationCommand(api, cwd, session, accountId, 'show', { envelopeId });
1270
+ if (response.envelope) {
1271
+ writeLocalVerificationEnvelope(cwd, response.envelope);
1272
+ return response.envelope;
1273
+ }
1274
+ } catch (_) {
1275
+ // Local evidence is still useful for diagnostics when the platform read fails.
1276
+ }
1277
+ return readLocalVerificationEnvelope(cwd, envelopeId);
1278
+ }
1279
+
1280
+ async function readVerificationPacketsForDiagnostics(api, cwd, session, accountId, envelopeId, filters = {}) {
1281
+ if (!envelopeId) return [];
1282
+ try {
1283
+ const response = await postVerificationCommand(api, cwd, session, accountId, 'packets', Object.assign({ envelopeId }, filters));
1284
+ if (Array.isArray(response.packets)) return response.packets;
1285
+ } catch (_) {
1286
+ // Local full-fidelity packet files remain the fallback when the platform read is unavailable.
1287
+ }
1288
+ return readLocalVerificationPackets(cwd, envelopeId, filters);
1289
+ }
1290
+
1150
1291
  function buildCommandWorld(response, fallback = {}) {
1151
1292
  const source = (response && response.staging) || {};
1152
1293
  const resolution = (response && response.resolution) || {};
@@ -1178,6 +1319,17 @@ function buildCommandWorld(response, fallback = {}) {
1178
1319
  };
1179
1320
  }
1180
1321
 
1322
+ // A variant sync writes overlays on exactly one component branch; say so in the packet world, where a manifest's
1323
+ // componentBranch is compared. Without it no sync_mutation could satisfy a manifest that declares one.
1324
+ function syncPacketWorld(response, world, branchName) {
1325
+ const sync = (response && response.sync) || {};
1326
+ const out = Object.assign({}, world || {});
1327
+ if (sync.mode === 'variant' && !out.componentBranch) {
1328
+ out.componentBranch = sync.branchName || branchName || null;
1329
+ }
1330
+ return out;
1331
+ }
1332
+
1181
1333
  function laneContentHashFrom(response) {
1182
1334
  if (!response) return null;
1183
1335
  const laneSummary = response.laneSummary || (response.staging && response.staging.laneSummary) ||
@@ -2395,21 +2547,23 @@ async function loggedPost(api, cwd, endpoint, payload, options = {}) {
2395
2547
  error = err;
2396
2548
  throw err;
2397
2549
  } finally {
2398
- const status = error ? 'error' : 'success';
2399
- const responseForLog = responseFile
2400
- ? { responseFile, note: 'Tool response stored externally' }
2401
- : sanitizeForLog(responseData);
2402
- appendSessionLog(cwd, {
2403
- ts: new Date().toISOString(),
2404
- requestId,
2405
- endpoint,
2406
- method: 'POST',
2407
- status,
2408
- durationMs: Date.now() - started,
2409
- request: sanitizeForLog(payload),
2410
- response: responseForLog,
2411
- error: error ? describeError(error) : null
2412
- });
2550
+ if (!options.skipSessionLog) {
2551
+ const status = error ? 'error' : 'success';
2552
+ const responseForLog = responseFile
2553
+ ? { responseFile, note: 'Tool response stored externally' }
2554
+ : sanitizeForLog(responseData);
2555
+ appendSessionLog(cwd, {
2556
+ ts: new Date().toISOString(),
2557
+ requestId,
2558
+ endpoint,
2559
+ method: 'POST',
2560
+ status,
2561
+ durationMs: Date.now() - started,
2562
+ request: sanitizeForLog(payload),
2563
+ response: responseForLog,
2564
+ error: error ? describeError(error) : null
2565
+ });
2566
+ }
2413
2567
  }
2414
2568
  }
2415
2569
 
@@ -3598,6 +3752,7 @@ function describeStageMode(mode) {
3598
3752
  function printStatusSummary(response, flags) {
3599
3753
  printBranchContext(response);
3600
3754
  printStagingLane(response.branchName, response.workspace, null);
3755
+ printStagingLaneOwnerNotice(response);
3601
3756
  printLaneSummary(response);
3602
3757
  printOrphanedWorkspaceWarning(response);
3603
3758
  printStagingFreshness(response.freshness);
@@ -3625,6 +3780,28 @@ function printStatusSummary(response, flags) {
3625
3780
  printComponentCommandResponse('Components staging', response, flags);
3626
3781
  }
3627
3782
 
3783
+ function stagingLaneOwnerNotice(response = {}) {
3784
+ const owner = response.laneOwnerAccountId != null ? response.laneOwnerAccountId :
3785
+ (response.staging && response.staging.laneOwnerAccountId);
3786
+ const checkout = response.checkoutAccountId != null ? response.checkoutAccountId :
3787
+ (response.accountId != null ? response.accountId : (response.staging && response.staging.checkoutAccountId));
3788
+ const stagedCount = Number(response.stagedCount != null ? response.stagedCount :
3789
+ (response.laneSummary && response.laneSummary.stagedCount != null ? response.laneSummary.stagedCount :
3790
+ (response.staging && response.staging.stagedCount != null ? response.staging.stagedCount :
3791
+ (response.staging && response.staging.laneSummary && response.staging.laneSummary.stagedCount))));
3792
+ if (owner == null || checkout == null || String(owner) === String(checkout) || !(stagedCount > 0)) {
3793
+ return [];
3794
+ }
3795
+ return [
3796
+ 'WARNING: staged entries shown here are under account ' + owner + ', not checkout account ' + checkout + '.',
3797
+ ' clear with: remits-cli components clear --all --account-id ' + owner
3798
+ ];
3799
+ }
3800
+
3801
+ function printStagingLaneOwnerNotice(response = {}) {
3802
+ stagingLaneOwnerNotice(response).forEach((line) => console.log(line));
3803
+ }
3804
+
3628
3805
  function printLanesSummary(response, flags) {
3629
3806
  printBranchContext(response);
3630
3807
  const lanes = Array.isArray(response.lanes) ? response.lanes : (Array.isArray(response.accountLanes) ? response.accountLanes : []);
@@ -3999,19 +4176,25 @@ async function lanesComponentsCommand(flags) {
3999
4176
  const dataMode = resolveDataMode(flags, session);
4000
4177
  const api = buildAxios(baseUrl, session.token);
4001
4178
 
4002
- const response = await loggedPost(api, cwd, '/cli/components', {
4179
+ const requestPayload = {
4003
4180
  token: session.token,
4004
4181
  accountId,
4005
4182
  branchName,
4006
4183
  workspace,
4007
4184
  dataMode,
4008
4185
  mode: 'lanes'
4009
- }).then((r) => r.data);
4186
+ };
4187
+
4188
+ let response = await loggedPost(api, cwd, '/cli/components', requestPayload).then((r) => r.data);
4010
4189
 
4011
4190
  if (!response.success) {
4012
4191
  throw new Error(response.message || 'Staging lanes failed');
4013
4192
  }
4014
4193
 
4194
+ if (flagEnabled(flags['wait-change']) || flagEnabled(flags.waitChange)) {
4195
+ response = await waitForLaneChange(api, cwd, requestPayload, response, flags);
4196
+ }
4197
+
4015
4198
  if (flagEnabled(flags.json)) {
4016
4199
  console.log(JSON.stringify(response, null, 2));
4017
4200
  return response;
@@ -4024,6 +4207,55 @@ async function lanesComponentsCommand(flags) {
4024
4207
  return response;
4025
4208
  }
4026
4209
 
4210
+ function laneWaitSignature(response, laneId) {
4211
+ const lanes = Array.isArray(response && response.lanes)
4212
+ ? response.lanes
4213
+ : (Array.isArray(response && response.accountLanes) ? response.accountLanes : []);
4214
+ const selected = laneId ? lanes.filter((lane) => String(lane.laneId || '') === String(laneId)) : lanes;
4215
+ return stableStringify(selected.map((lane) => ({
4216
+ laneId: lane.laneId,
4217
+ accountId: lane.accountId,
4218
+ branchName: lane.branchName,
4219
+ workspace: lane.workspace || null,
4220
+ stagedCount: lane.stagedCountAsOf || lane.stagedCount || 0,
4221
+ contentHash: lane.contentHash || (lane.laneSummary && lane.laneSummary.contentHash) || null,
4222
+ updatedAtMs: lane.updatedAtMs || null
4223
+ })));
4224
+ }
4225
+
4226
+ async function waitForLaneChange(api, cwd, requestPayload, initialResponse, flags) {
4227
+ const laneId = flags['lane-id'] || flags.laneId || flags.lane;
4228
+ const timeoutSeconds = parsePositiveInt(flags.timeout || flags['timeout-seconds'] || flags.timeoutSeconds, 600);
4229
+ const pollMs = parsePositiveInt(flags['poll-ms'] || flags.pollMs, 2000);
4230
+ const started = Date.now();
4231
+ const initialSignature = laneWaitSignature(initialResponse, laneId);
4232
+ if (!flagEnabled(flags.json)) {
4233
+ console.log('Waiting for staging lane change' + (laneId ? ' on ' + laneId : '') + ' for up to ' + timeoutSeconds + 's...');
4234
+ }
4235
+ while (Date.now() - started < timeoutSeconds * 1000) {
4236
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
4237
+ const next = await loggedPost(api, cwd, '/cli/components', requestPayload, { skipSessionLog: true }).then((r) => r.data);
4238
+ if (!next.success) {
4239
+ throw new Error(next.message || 'Staging lanes failed while waiting for a change');
4240
+ }
4241
+ if (laneWaitSignature(next, laneId) !== initialSignature) {
4242
+ next.waitChange = {
4243
+ changed: true,
4244
+ laneId: laneId || null,
4245
+ waitedMs: Date.now() - started
4246
+ };
4247
+ return next;
4248
+ }
4249
+ }
4250
+ initialResponse.waitChange = {
4251
+ changed: false,
4252
+ laneId: laneId || null,
4253
+ waitedMs: Date.now() - started,
4254
+ timeoutSeconds
4255
+ };
4256
+ return initialResponse;
4257
+ }
4258
+
4027
4259
  async function entriesComponentsCommand(flags) {
4028
4260
  const cwd = process.cwd();
4029
4261
  ensureLocalState(cwd);
@@ -4219,6 +4451,30 @@ async function syncComponentsCommand(rawFlags) {
4219
4451
  }
4220
4452
  throw new Error(mismatch + ' Nothing was synced.');
4221
4453
  }
4454
+ const featureRefusal = !namesOnly ? featureBranchSyncRefusal(branchContext, branchName, flags) : null;
4455
+ if (featureRefusal) {
4456
+ if (flagEnabled(flags.json)) {
4457
+ const refusal = {
4458
+ success: false,
4459
+ mode: 'sync',
4460
+ dataMode,
4461
+ accountId,
4462
+ branchName,
4463
+ workspace,
4464
+ dryRun,
4465
+ safe,
4466
+ branchContext,
4467
+ repositoryCheck: preflightRepoCheck,
4468
+ refusal: 'feature_branch_landing',
4469
+ gateViolations: [featureRefusal],
4470
+ message: featureRefusal
4471
+ };
4472
+ console.log(JSON.stringify(refusal, null, 2));
4473
+ process.exitCode = 1;
4474
+ return refusal;
4475
+ }
4476
+ throw new Error(featureRefusal + ' Nothing was synced.');
4477
+ }
4222
4478
  if (branchContextIsTrunk(branchContext)) {
4223
4479
  // Trunk has no dry-run plan to gate on, and pretending otherwise would be worse than refusing:
4224
4480
  // an agent would read "safe" and get an ungated authoritative reconcile. Say what trunk sync is
@@ -4335,7 +4591,7 @@ async function syncComponentsCommand(rawFlags) {
4335
4591
  type: response.sync && response.sync.dryRun ? 'sync_dry_run' : 'sync_mutation',
4336
4592
  success: response.success !== false,
4337
4593
  claim: response.sync && response.sync.dryRun ? 'Component sync dry-run observed' : 'Component sync completed',
4338
- world: buildCommandWorld(response, { accountId, dataMode, branchName, workspace, host: normalizeBaseUrl(baseUrl), sourceLayer: response.sync && response.sync.mode === 'variant' ? 'variant' : 'trunk' }),
4594
+ world: syncPacketWorld(response, buildCommandWorld(response, { accountId, dataMode, branchName, workspace, host: normalizeBaseUrl(baseUrl), sourceLayer: response.sync && response.sync.mode === 'variant' ? 'variant' : 'trunk' }), branchName),
4339
4595
  revision: Object.assign(collectVerificationSource(cwd, flags), {
4340
4596
  platformSyncedSha: response.sync && (response.sync.postSyncSha || response.sync.branchHeadSha)
4341
4597
  }),
@@ -4344,7 +4600,7 @@ async function syncComponentsCommand(rawFlags) {
4344
4600
  { category: response.sync && response.sync.dryRun ? 'sync_dry_run' : 'sync_mutation', summary }
4345
4601
  ],
4346
4602
  rawRefs: { command: 'components sync' },
4347
- sync: response.sync,
4603
+ sync: syncEvidencePayload(response.sync),
4348
4604
  summary
4349
4605
  }, { quiet: flagEnabled(flags.json) });
4350
4606
 
@@ -4375,11 +4631,31 @@ async function syncComponentsCommand(rawFlags) {
4375
4631
  return response;
4376
4632
  }
4377
4633
  console.log('Components sync:', JSON.stringify(response, null, 2));
4634
+ printVariantSyncWrites(summary);
4378
4635
  printPromotionSignals(response);
4379
4636
  failOnSyncGate(gate);
4380
4637
  return response;
4381
4638
  }
4382
4639
 
4640
+ // The one line a person needs from a variant sync of a long-lived branch: what THIS sync changes, apart from
4641
+ // the overlays it merely re-confirms (the totals alone read the same on every sync).
4642
+ function printVariantSyncWrites(summary) {
4643
+ const writes = summary && summary.writes;
4644
+ if (!writes || summary.mode !== 'variant') return;
4645
+ console.log('');
4646
+ if (!writes.known) {
4647
+ console.log('Overlay changes: unknown (this platform does not report which overlays were already stored).');
4648
+ return;
4649
+ }
4650
+ const verb = summary.dryRun ? 'would change' : 'changed';
4651
+ console.log('Overlay changes: ' + writes.changed + ' ' + verb + ', ' + writes.alreadyCurrent + ' already current, ' +
4652
+ writes.removed + ' removed, ' + writes.pruned + ' pruned (converged back to trunk)');
4653
+ (writes.changedComponents || []).forEach((entry) => {
4654
+ console.log(' ' + String(entry.type || 'component').toLowerCase() + ':' + (entry.id != null ? entry.id : entry.name) +
4655
+ (entry.name && entry.id != null ? ' ' + entry.name : ''));
4656
+ });
4657
+ }
4658
+
4383
4659
  // A sync is a step in a longer loop, and the steps after it are the ones that get skipped — so print what
4384
4660
  // the server just observed about this branch's relationship to trunk, not only what it wrote.
4385
4661
  function printPromotionSignals(response) {
@@ -4548,7 +4824,9 @@ function syncPlanEntries(response) {
4548
4824
  type: String(entry.type || entry.kind || 'component').toLowerCase(),
4549
4825
  id: entry.id == null ? null : String(entry.id),
4550
4826
  name: entry.name || null,
4551
- path: entry.path || null
4827
+ path: entry.path || null,
4828
+ // true when the overlay already stored for this component matches the branch: re-confirmed, not changed.
4829
+ storedCurrent: entry.storedCurrent === true
4552
4830
  });
4553
4831
  });
4554
4832
  });
@@ -4594,6 +4872,8 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
4594
4872
  const expectedRemoved = parseExpectedRemoved(flags);
4595
4873
  const violations = [];
4596
4874
  const checks = {};
4875
+ // Overlays the --changed-only gate did not count because the platform already stores them with this content.
4876
+ let alreadyCurrentCount = null;
4597
4877
 
4598
4878
  if (flagEnabled(flags['fail-on-errors']) || flagEnabled(flags.failOnErrors)) {
4599
4879
  checks.failOnErrors = errors.length === 0;
@@ -4633,13 +4913,20 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
4633
4913
  const allowedIds = new Set(changedSet.filter((c) => c.id != null).map((c) => c.type + ':' + c.id));
4634
4914
  const allowedNames = new Set(changedSet.filter((c) => c.name).map((c) => c.type + ':' + String(c.name).toLowerCase()));
4635
4915
  const allowedPaths = new Set([].concat(...changedSet.map((c) => (Array.isArray(c.paths) ? c.paths : []))).map(String));
4916
+ // An overlay the platform ALREADY stores with this exact content is not something this sync changes. A
4917
+ // long-lived branch (a permanent customer release train) re-reports every one of its overlays on every sync,
4918
+ // so counting those made the gate refuse ordinary work with "plan touches 80 components" and blame a stale
4919
+ // branch that `components promotion` reported as ready. Removals are never exempt.
4920
+ const alreadyCurrent = entries.filter((entry) => entry.storedCurrent && entry.bucket !== 'removed');
4636
4921
  const unexpected = entries.filter((entry) => {
4922
+ if (entry.storedCurrent && entry.bucket !== 'removed') return false;
4637
4923
  if (entry.path && allowedPaths.has(String(entry.path))) return false;
4638
4924
  if (entry.id != null && allowedIds.has(entry.type + ':' + entry.id)) return false;
4639
4925
  if (entry.name && allowedNames.has(entry.type + ':' + String(entry.name).toLowerCase())) return false;
4640
4926
  return true;
4641
4927
  });
4642
4928
  checks.changedOnly = unexpected.length === 0;
4929
+ alreadyCurrentCount = alreadyCurrent.length;
4643
4930
  if (unexpected.length) {
4644
4931
  // Name the most likely cause instead of only the symptom. An empty changed set with a non-empty
4645
4932
  // plan is the ordinary post-commit state, not evidence that the plan is dangerous.
@@ -4651,11 +4938,10 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
4651
4938
  // physically carries old copies of files nobody on it touched, and a variant sync turns each of
4652
4939
  // those into an unrelated override. Naming the cause is the difference between a gate that
4653
4940
  // stops the damage and a gate that also tells you how to clear it.
4654
- : (changedSet.length && unexpected.length > changedSet.length)
4655
- ? '\n Most likely cause: this branch is BEHIND trunk and still carries old copies of files ' +
4656
- 'it never changed. A variant sync turns each of those into an unrelated override.' +
4657
- '\n Next step: merge trunk into this branch, push, then re-run the same command:' +
4658
- '\n git merge <trunk> && git push origin <branch>'
4941
+ // With --changed-since the empty-set hint above does not apply, so a ref that shows no committed change
4942
+ // still needs the cause named — that is exactly the behind-trunk branch whose plan is all stale copies.
4943
+ : ((changedSet.length || since) && unexpected.length > changedSet.length)
4944
+ ? staleBranchGateHint(response)
4659
4945
  : '';
4660
4946
  violations.push('--changed-only: plan touches ' + unexpected.length + ' component(s) this checkout did not change: ' +
4661
4947
  unexpected.slice(0, 20).map(syncEntryToken).join(', ') + (unexpected.length > 20 ? ', ...' : '') + hint);
@@ -4663,7 +4949,28 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
4663
4949
  }
4664
4950
  }
4665
4951
 
4666
- return { violations, checks };
4952
+ return { violations, checks, alreadyCurrentCount };
4953
+ }
4954
+
4955
+ // Name the stale-branch cause only when the platform's own git comparison says the branch IS behind trunk. The
4956
+ // hint used to fire on the count alone, and told agents on an up-to-date permanent branch to merge trunk in.
4957
+ function staleBranchGateHint(response) {
4958
+ const sync = (response && response.sync) || {};
4959
+ const comparison = sync.branchComparison || (sync.promotion && sync.promotion.git) || null;
4960
+ const behindBy = comparison && Number.isFinite(Number(comparison.behindBy)) ? Number(comparison.behindBy) : null;
4961
+ if (behindBy !== null && behindBy > 0) {
4962
+ return '\n Most likely cause: this branch is BEHIND trunk by ' + behindBy + ' commit(s) and still carries old copies ' +
4963
+ 'of files it never changed. A variant sync turns each of those into an unrelated override.' +
4964
+ '\n Next step: merge trunk into this branch, push, then re-run the same command:' +
4965
+ '\n git merge <trunk> && git push origin <branch>';
4966
+ }
4967
+ if (behindBy === 0) {
4968
+ return '\n The branch is current with trunk, so these are not stale copies: they are overlays whose stored content ' +
4969
+ 'differs from this branch but that this checkout\'s changed set does not name. Widen --changed-since to the ' +
4970
+ 'commit the platform last synced (components promotion shows it), or review the plan with --dry-run.';
4971
+ }
4972
+ return '\n The platform could not compare this branch with trunk. Run `remits-cli components promotion` to see ' +
4973
+ 'whether it is behind before merging anything.';
4667
4974
  }
4668
4975
 
4669
4976
  function failOnSyncGate(gate) {
@@ -4781,6 +5088,15 @@ function featureBranchCommitRefusal(branchContext, branchName, flags) {
4781
5088
  'deliberately creating a NEW variant branch, re-run with --create-variant-branch.';
4782
5089
  }
4783
5090
 
5091
+ function featureBranchSyncRefusal(branchContext, branchName, flags) {
5092
+ if (!branchContext || branchContext.variantBranchSource !== 'subscription-fallback') return null;
5093
+ if (flags && (flags['create-variant-branch'] === true || flags['create-variant-branch'] === 'true')) return null;
5094
+ return 'components sync refused: \'' + branchName + '\' is not a variant branch (no committed variants, no ' +
5095
+ 'subscribers). ' + featureBranchLandingHint(branchContext) + ' Syncing here would write overlays for \'' +
5096
+ branchName + '\' that nobody subscribes to, and flip every other lane on that branch to them. If you are ' +
5097
+ 'deliberately creating a NEW variant branch, re-run with --create-variant-branch.';
5098
+ }
5099
+
4784
5100
  // What a verification envelope records as the world it verified. Taken from the platform's answer
4785
5101
  // (`variantWorld`, the same rule every run uses), never inferred from `onTrunk`: a feature branch cut from a
4786
5102
  // variant branch is not on trunk, yet resolves the SUBSCRIBED branch, and labelling it by its own name recorded
@@ -4947,6 +5263,36 @@ function printTrunkSyncWarning(trunkBranch, options) {
4947
5263
  emit(' For a gated plan, work on a variant branch instead.');
4948
5264
  }
4949
5265
 
5266
+ function variantSyncWrites(overridden, added, removed, unchanged) {
5267
+ const overlays = overridden.concat(added);
5268
+ const known = overlays.every((entry) => entry && typeof entry.storedCurrent === 'boolean');
5269
+ const pruned = unchanged.filter((entry) => entry && entry.pruned === true).length;
5270
+ if (!known) {
5271
+ return { changed: null, alreadyCurrent: null, removed: removed.length, pruned, known: false };
5272
+ }
5273
+ const changed = overlays.filter((entry) => !entry.storedCurrent);
5274
+ return {
5275
+ changed: changed.length,
5276
+ changedComponents: changed.slice(0, 50).map((entry) => ({ type: entry.type, name: entry.name, id: entry.id })),
5277
+ alreadyCurrent: overlays.length - changed.length,
5278
+ removed: removed.length,
5279
+ pruned,
5280
+ known: true
5281
+ };
5282
+ }
5283
+
5284
+ // `|` separates selectors and a repeated --names needs no separator. A COMMA is never a separator any more: it
5285
+ // is sent as part of the selector, and the platform matches a comma-bearing selector either as one whole case name
5286
+ // ("… the complete statement, offer, and surcharge matrix") or, for older invocations, as its comma-separated
5287
+ // parts (Test.selectorMatchesCase). Splitting in the CLI took the real name apart before the platform could see it.
5288
+ function parseTestCaseSelectors(rawNames) {
5289
+ return (Array.isArray(rawNames) ? rawNames : [rawNames])
5290
+ .filter((value) => value !== undefined && value !== null)
5291
+ .flatMap((value) => String(value).split('|'))
5292
+ .map((value) => value.trim())
5293
+ .filter(Boolean);
5294
+ }
5295
+
4950
5296
  function buildSyncSummary(response) {
4951
5297
  const sync = (response && response.sync) || {};
4952
5298
  const results = sync.syncResults || {};
@@ -5076,6 +5422,10 @@ function buildSyncSummary(response) {
5076
5422
  gateViolations: (response && response.gateViolations) || null,
5077
5423
  overridden: overridden.length,
5078
5424
  added: added.length,
5425
+ // What THIS sync changes, as opposed to the whole overlay set it re-reports. `overridden`/`added` are totals,
5426
+ // so five consecutive syncs of a long-lived branch all printed `overridden: 48, added: 32` and nobody could see
5427
+ // which overlays their own sync moved. Null counts mean the platform predates the storedCurrent flag.
5428
+ writes: variantSyncWrites(overridden, added, removed, unchanged),
5079
5429
  removed: removed.map((entry) => ({
5080
5430
  type: entry.type,
5081
5431
  kind: entry.kind,
@@ -5916,6 +6266,94 @@ function testCompileSignatures(status = {}) {
5916
6266
  .filter(Boolean);
5917
6267
  }
5918
6268
 
6269
+ function testRunSuiteNameFromStatus(status = {}) {
6270
+ return status.test && status.test.name ? String(status.test.name) : null;
6271
+ }
6272
+
6273
+ function testRunSuiteNameFromPacket(packet = {}) {
6274
+ return packet.test && packet.test.testName
6275
+ ? String(packet.test.testName)
6276
+ : (packet.status && packet.status.test && packet.status.test.name ? String(packet.status.test.name) : null);
6277
+ }
6278
+
6279
+ function testCaseOutcomesFromStatusLike(status = {}) {
6280
+ const tests = status.result && Array.isArray(status.result.tests) ? status.result.tests :
6281
+ (status.test && Array.isArray(status.test.tests) ? status.test.tests : []);
6282
+ return tests
6283
+ .filter((test) => test && test.name && typeof test.passed === 'boolean')
6284
+ .map((test) => ({ name: String(test.name), passed: test.passed === true }));
6285
+ }
6286
+
6287
+ function provenanceSignature(value) {
6288
+ const rows = Array.isArray(value) ? value : [];
6289
+ return sha256(stableStringify(rows.map((entry) => ({
6290
+ type: entry && entry.type || null,
6291
+ id: entry && entry.id || null,
6292
+ name: entry && entry.name || null,
6293
+ signature: entry && entry.signature || null,
6294
+ source: entry && entry.source || null
6295
+ }))));
6296
+ }
6297
+
6298
+ function packetEvidenceIdentity(packet = {}) {
6299
+ const world = packet.world || {};
6300
+ const revision = packet.revision || {};
6301
+ const laneSummary = packet.laneSummary || (packet.componentStatus && packet.componentStatus.laneSummary) || {};
6302
+ return {
6303
+ laneContentHash: world.laneContentHash || world.stagedOverlayHash || revision.laneContentHash ||
6304
+ revision.stagedOverlayHash || laneSummary.contentHash || null,
6305
+ gitHead: revision.gitHead || null
6306
+ };
6307
+ }
6308
+
6309
+ function sameNondeterminismIdentity(left = {}, right = {}) {
6310
+ return !!(left.laneContentHash && right.laneContentHash && left.gitHead && right.gitHead &&
6311
+ String(left.laneContentHash) === String(right.laneContentHash) &&
6312
+ String(left.gitHead) === String(right.gitHead));
6313
+ }
6314
+
6315
+ function detectNondeterministicTestRun(priorPackets, currentStatus, currentProvenance, currentIdentity = {}) {
6316
+ const packets = Array.isArray(priorPackets) ? priorPackets : [];
6317
+ const suiteName = testRunSuiteNameFromStatus(currentStatus);
6318
+ const currentCases = testCaseOutcomesFromStatusLike(currentStatus);
6319
+ if (!suiteName || !currentCases.length) return null;
6320
+ if (!Array.isArray(currentProvenance) || !currentProvenance.length) return null;
6321
+ if (!currentIdentity.laneContentHash || !currentIdentity.gitHead) return null;
6322
+
6323
+ const currentSignature = provenanceSignature(currentProvenance);
6324
+ const currentByName = new Map(currentCases.map((outcome) => [outcome.name, outcome]));
6325
+ for (let i = packets.length - 1; i >= 0; i -= 1) {
6326
+ const packet = packets[i];
6327
+ if (!packet || packet.type !== 'test_run') continue;
6328
+ if (testRunSuiteNameFromPacket(packet) !== suiteName) continue;
6329
+ const previousProvenance = packet.revision && packet.revision.componentProvenance;
6330
+ if (!Array.isArray(previousProvenance) || !previousProvenance.length) continue;
6331
+ if (!sameNondeterminismIdentity(currentIdentity, packetEvidenceIdentity(packet))) continue;
6332
+ const previousSignature = provenanceSignature(previousProvenance);
6333
+ if (previousSignature !== currentSignature) continue;
6334
+ const previousCases = testCaseOutcomesFromStatusLike(packet.status || packet);
6335
+ const flips = previousCases
6336
+ .map((previous) => {
6337
+ const current = currentByName.get(previous.name);
6338
+ return current && current.passed !== previous.passed
6339
+ ? { caseName: previous.name, previousPassed: previous.passed, currentPassed: current.passed, previousPacketId: packet.packetId }
6340
+ : null;
6341
+ })
6342
+ .filter(Boolean);
6343
+ if (flips.length) {
6344
+ return {
6345
+ nondeterministic: true,
6346
+ message: 'Outcome changed with no source change - likely nondeterministic (AI/live data).',
6347
+ suite: suiteName,
6348
+ provenanceSignature: currentSignature,
6349
+ previousPacketId: packet.packetId,
6350
+ flips
6351
+ };
6352
+ }
6353
+ }
6354
+ return null;
6355
+ }
6356
+
5919
6357
  function printTestRunPivots(status = {}) {
5920
6358
  const tests = status.result && Array.isArray(status.result.tests) ? status.result.tests : [];
5921
6359
  if (!tests.length) return;
@@ -6041,26 +6479,12 @@ async function testCommand(flags) {
6041
6479
  throw new Error('Missing --test <id-or-name>');
6042
6480
  }
6043
6481
 
6044
- // Test case names are English prose, so COMMAS occur in them naturally. A comma-delimited selector
6045
- // therefore split a legitimate name in half, matched nothing, and (before the server learned to
6046
- // report unmatched selectors) reported a clean run of zero cases. `|` is the delimiter now; the
6047
- // comma still works when no `|` is present, so existing invocations keep behaving as before.
6048
- // `--names` may also be repeated, which needs no delimiter at all.
6482
+ // Test case names are English prose, so COMMAS occur in them naturally. `|` (or repeating --names) is the
6483
+ // only delimiter; see parseTestCaseSelectors for how a comma-bearing selector is matched.
6049
6484
  const rawNames = flags.names === undefined || flags.names === null
6050
6485
  ? []
6051
6486
  : (Array.isArray(flags.names) ? flags.names : [flags.names]);
6052
- const repeated = rawNames.length > 1;
6053
- const names = rawNames
6054
- .flatMap((value) => {
6055
- const text = String(value);
6056
- if (text.includes('|')) return text.split('|');
6057
- // Comma-splitting is the legacy behaviour, kept so existing invocations still work. It is applied
6058
- // ONLY to a single --names value: repeating the flag is already unambiguous, so splitting there
6059
- // would take a deliberate literal name back apart.
6060
- return repeated ? [text] : text.split(',');
6061
- })
6062
- .map((s) => s.trim())
6063
- .filter(Boolean);
6487
+ const names = parseTestCaseSelectors(rawNames);
6064
6488
 
6065
6489
  const api = buildAxios(baseUrl, session.token);
6066
6490
  // --as-account runs the Test AS a (descendant) subscriber account, so that account's relationship
@@ -6102,6 +6526,7 @@ async function testCommand(flags) {
6102
6526
  printSessionResolutionWarning(sessionContext);
6103
6527
  printResolvedBaseUrl(baseUrl);
6104
6528
  printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
6529
+ printStagingLaneOwnerNotice(start.staging || {});
6105
6530
  if (start.staging && Array.isArray(start.staging.accountLanes)) {
6106
6531
  printOrphanedWorkspaceWarning(start.staging);
6107
6532
  printAccountLanes({ accountLanes: start.staging.accountLanes, branchName, workspace });
@@ -6130,6 +6555,7 @@ async function testCommand(flags) {
6130
6555
  console.log(JSON.stringify(status, null, 2));
6131
6556
  } else {
6132
6557
  console.log('Final status:', JSON.stringify(status, null, 2));
6558
+ printStagingLaneOwnerNotice(status.staging || {});
6133
6559
  printTestRunPivots(status);
6134
6560
  }
6135
6561
 
@@ -6155,15 +6581,38 @@ async function testCommand(flags) {
6155
6581
  process.exitCode = 1;
6156
6582
  }
6157
6583
 
6584
+ const verificationContext = activeVerificationContext(cwd, flags, session, accountId);
6585
+ const activeEnvelopeId = verificationEnvelopeIdForCommand(cwd, flags, verificationContext);
6586
+ const componentProvenance = testComponentProvenance(status);
6587
+ const sourceRevision = collectVerificationSource(cwd, flags);
6588
+ const priorPackets = await readVerificationPacketsForDiagnostics(api, cwd, session, accountId, activeEnvelopeId, {
6589
+ packetType: 'test_run',
6590
+ suite: status.test && status.test.name,
6591
+ max: 50
6592
+ });
6593
+ const nondeterminism = detectNondeterministicTestRun(priorPackets, status, componentProvenance, {
6594
+ laneContentHash: status.staging && status.staging.laneSummary && status.staging.laneSummary.contentHash,
6595
+ gitHead: sourceRevision.gitHead
6596
+ });
6597
+ if (nondeterminism && !jsonOutput) {
6598
+ console.log('Nondeterministic signal:', nondeterminism.message);
6599
+ nondeterminism.flips.slice(0, 6).forEach((flip) => {
6600
+ console.log(' - ' + flip.caseName + ': ' + (flip.previousPassed ? 'passed' : 'failed') + ' -> ' + (flip.currentPassed ? 'passed' : 'failed') +
6601
+ ' (previous packet ' + (flip.previousPacketId || 'unknown') + ')');
6602
+ });
6603
+ }
6604
+
6158
6605
  await appendVerificationPacket(api, cwd, session, accountId, flags, {
6159
6606
  type: 'test_run',
6160
6607
  success: status.status === 'completed' && !(status.result && status.result.failed > 0) && !unmatched.length,
6161
6608
  claim: 'Test run ' + String(testRef),
6162
6609
  world: buildCommandWorld(status, { accountId, dataMode: status.dataMode || dataMode, dataModeSource, branchName, workspace, host: normalizeBaseUrl(baseUrl), sourceLayer: status.staging && status.staging.testComponentSource }),
6163
- revision: Object.assign(collectVerificationSource(cwd, flags), {
6610
+ revision: Object.assign(sourceRevision, {
6164
6611
  compileSignatures: testCompileSignatures(status),
6165
- componentProvenance: testComponentProvenance(status)
6612
+ componentProvenance
6166
6613
  }),
6614
+ nondeterministic: nondeterminism ? true : undefined,
6615
+ nondeterminism: nondeterminism || undefined,
6167
6616
  test: {
6168
6617
  taskId: start.taskId,
6169
6618
  testId: status.test && status.test.id,
@@ -6176,7 +6625,9 @@ async function testCommand(flags) {
6176
6625
  unmatchedTestNames: unmatched
6177
6626
  },
6178
6627
  evidenceCategories: testEvidenceCategories(status, names),
6179
- limitations: unmatched.length ? ['One or more requested test case selectors matched no case.'] : [],
6628
+ limitations: []
6629
+ .concat(unmatched.length ? ['One or more requested test case selectors matched no case.'] : [])
6630
+ .concat(nondeterminism ? [nondeterminism.message] : []),
6180
6631
  rawRefs: { testStatusKey: start.taskId },
6181
6632
  status
6182
6633
  }, { quiet: jsonOutput });
@@ -6564,8 +7015,7 @@ async function toolCommand(flags) {
6564
7015
  console.log('Data mode:', data.dataMode || dataMode);
6565
7016
  if (data.threadGroupingId) console.log('Thread grouping ID:', data.threadGroupingId);
6566
7017
  console.log('Session log:', data.sessionLog);
6567
- const responseEvidence = fileEvidence(data.responseFile);
6568
- console.log('Tool response file:', data.responseFile + (responseEvidence.bytes ? ' (' + responseEvidence.bytes + ' bytes, sha256 ' + responseEvidence.sha256.slice(0, 12) + ')' : ''));
7018
+ printToolResponseFileLine('Tool response file', data.responseFile);
6569
7019
  if (polledFailed) {
6570
7020
  console.log('Tool error:', data.toolMessage);
6571
7021
  }
@@ -6636,8 +7086,7 @@ async function toolCommand(flags) {
6636
7086
  console.log('Component:', `${data.componentSource}${sig}`);
6637
7087
  }
6638
7088
  console.log('Session log:', data.sessionLog);
6639
- const responseEvidence = fileEvidence(data.responseFile);
6640
- console.log('Tool response file:', data.responseFile + (responseEvidence.bytes ? ' (' + responseEvidence.bytes + ' bytes, sha256 ' + responseEvidence.sha256.slice(0, 12) + ')' : ''));
7089
+ printToolResponseFileLine('Tool response file', data.responseFile);
6641
7090
  }
6642
7091
 
6643
7092
  // Non-zero exit so scripted/agent callers that check status notice the refusal too.
@@ -6661,8 +7110,7 @@ async function toolCommand(flags) {
6661
7110
  evidenceResponseFile = finalStatus.responseFile;
6662
7111
  if (!jsonOutput) {
6663
7112
  console.log('Final status:', finalStatus.data.status);
6664
- const finalEvidence = fileEvidence(finalStatus.responseFile);
6665
- console.log('Tool response file:', finalStatus.responseFile + (finalEvidence.bytes ? ' (' + finalEvidence.bytes + ' bytes, sha256 ' + finalEvidence.sha256.slice(0, 12) + ')' : ''));
7113
+ printToolResponseFileLine('Tool response file', finalStatus.responseFile);
6666
7114
  }
6667
7115
  if (finalStatus.data.status !== 'completed') {
6668
7116
  process.exitCode = 1;
@@ -6673,6 +7121,7 @@ async function toolCommand(flags) {
6673
7121
  await appendVerificationPacket(api, cwd, session, accountId, flags, {
6674
7122
  type: 'tool_call',
6675
7123
  success: asyncMode && !waitForAsync ? null : (!toolFailed && (!asyncMode || process.exitCode !== 1)),
7124
+ pending: asyncMode && !waitForAsync,
6676
7125
  claim: 'Tool call ' + String(toolName),
6677
7126
  world: buildCommandWorld(evidenceData, { accountId: evidenceData.accountId || accountId, dataMode: evidenceData.dataMode || dataMode, branchName: evidenceData.branchName || branchName, workspace: resolveWorkspace(cwd, flags), host: normalizeBaseUrl(baseUrl), sourceLayer: evidenceData.componentSource }),
6678
7127
  revision: Object.assign(collectVerificationSource(cwd, flags), {
@@ -6753,6 +7202,12 @@ async function verifyCommand(flags, subcommand) {
6753
7202
  if (flags.manifest || flags.file) {
6754
7203
  manifest = parseManifestFile(flags.manifest || flags.file);
6755
7204
  }
7205
+ if (dataMode === 'prod' && !manifestRequiredEvidence(manifest).length && !flagEnabled(flags['no-contract'])) {
7206
+ console.error('');
7207
+ console.error('Warning: starting a prod-data verification envelope with no required evidence contract.');
7208
+ console.error('Attach a manifest with requiredEvidence before destructive work, or pass --no-contract when this is intentionally evidence-only.');
7209
+ console.error('');
7210
+ }
6756
7211
  let statusResponse = null;
6757
7212
  try {
6758
7213
  statusResponse = await loggedPost(api, cwd, '/cli/components', {
@@ -6788,6 +7243,7 @@ async function verifyCommand(flags, subcommand) {
6788
7243
  console.log('Verification envelope started:', envelope.envelopeId);
6789
7244
  console.log('Target:', 'account=' + accountId + ', branch=' + branchName + ', workspace=' + (workspace || 'shared') + ', dataMode=' + dataMode);
6790
7245
  printAcceptanceWarnings(envelope);
7246
+ printEnvelopeWarnings(envelope);
6791
7247
  console.log('Local mirror:', verificationPaths(cwd, envelope.envelopeId).base);
6792
7248
  return envelope;
6793
7249
  }
@@ -6829,6 +7285,7 @@ async function verifyCommand(flags, subcommand) {
6829
7285
  console.log('Manifest attached to envelope:', envelopeId);
6830
7286
  console.log('Required evidence:', (((response.envelope || {}).acceptance || {}).requiredEvidence || []).length);
6831
7287
  printAcceptanceWarnings(response.envelope);
7288
+ printEnvelopeWarnings(response.envelope);
6832
7289
  return response.envelope;
6833
7290
  }
6834
7291
 
@@ -12521,11 +12978,12 @@ function printComponentsHelp(subcommand) {
12521
12978
  return;
12522
12979
  }
12523
12980
  if (subcommand === 'lanes' || subcommand === 'lane') {
12524
- console.log('Usage: remits-cli components lanes [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--json]');
12981
+ console.log('Usage: remits-cli components lanes [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--wait-change [--lane-id ID] [--timeout 600]] [--json]');
12525
12982
  console.log('');
12526
12983
  console.log('Lists every indexed staging lane on the account, across users, branches, and workspaces.');
12527
12984
  console.log('This is read-only and uses the same lane registry that powers the admin Platforms view.');
12528
12985
  console.log('Use `components entries --lane-id <id>` to inspect the actual staged files in a lane.');
12986
+ console.log('Use --wait-change to wait until the lane registry changes instead of polling in a loop.');
12529
12987
  return;
12530
12988
  }
12531
12989
  if (subcommand === 'entries' || subcommand === 'entry' || subcommand === 'staged') {
@@ -12564,7 +13022,7 @@ function printComponentsHelp(subcommand) {
12564
13022
  console.log('so several agents can iterate at once. See: remits-cli workspace --help');
12565
13023
  console.log(' remits-cli components stage [--workset|--changed-only] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
12566
13024
  console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
12567
- console.log(' remits-cli components lanes [--base-url URL] [--account-id ID] [--json]');
13025
+ console.log(' remits-cli components lanes [--base-url URL] [--account-id ID] [--wait-change [--lane-id ID] [--timeout 600]] [--json]');
12568
13026
  console.log(' remits-cli components entries --lane-id ID [--base-url URL] [--account-id ID] [--json|--verbose]');
12569
13027
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
12570
13028
  console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remits/remits-cli",
3
- "version": "0.1.127",
3
+ "version": "0.1.129",
4
4
  "description": "Local CLI for auth, component sync, and live test execution against Remits",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -363,7 +363,9 @@ The analogous hazard is different, and you must still respect it:
363
363
  explicitly.
364
364
  - **Use `components sync --dry-run` before risky variant syncs.** It reports `overridden`, `added`,
365
365
  `removed`, `unchanged`, `skipped`, and `errors` without writing rows, caching the sync SHA, or clearing
366
- staging. Existing overlay ids appear as `variantId`; an `unchanged` row with `pruned:true` means the
366
+ staging. `overridden`/`added` are the branch's WHOLE overlay set; read the `Overlay changes:` line (or
367
+ `writes` in `--summary`) for what this sync actually changes, and `storedCurrent: true` on an entry for
368
+ an overlay that is already stored as-is. Existing overlay ids appear as `variantId`; an `unchanged` row with `pruned:true` means the
367
369
  branch has converged back to trunk and the overlay would be removed. It is rejected on trunk, and
368
370
  `components commit --dry-run` is unsupported because `commit` performs compile validation and local git
369
371
  writes before syncing.
@@ -72,6 +72,11 @@ Think about remits-cli as two cooperating layers:
72
72
  - where a large tool response was written
73
73
  - which tool schemas were most recently cached here
74
74
 
75
+ Tool response files under `./.remits-cli/tool-responses/` are the full CLI results. The CLI may print a
76
+ size/hash hint for large files so you can inspect them selectively with `jq`, `rg`, or byte-range reads,
77
+ but it does not truncate the local JSON file. Do not confuse this with the in-platform OpenRouter client,
78
+ which can offload large tool results inside the model conversation and inject a read-back tool for the AI.
79
+
75
80
  When a user asks an indirect question, map it to the right layer first:
76
81
 
77
82
  - "Why did this ticket open in the wrong repo?" → start in global state.
@@ -85,7 +85,10 @@ deterministically:
85
85
  remits-cli tool --name "mcp_run_action" --input '{"accountId":49,"actionId":200,"executionMode":"async","actionRunId":"my-stable-run-id","actionInput":{"sourceDocumentId":"..."}}' --data-mode prod
86
86
  ```
87
87
 
88
- Every tool response is saved to `./.remits-cli/tool-responses/<callId>.json`.
88
+ Every tool response is saved in full to `./.remits-cli/tool-responses/<callId>.json`. Large responses may
89
+ print an early size/hash line and a selective-read hint before any preview text, but the saved CLI file is
90
+ not truncated or offloaded. That is separate from the platform's OpenRouter model loop, where the AI
91
+ client may offload large tool results before feeding context back to the model.
89
92
 
90
93
  ### Hierarchy-scoped tool reads
91
94
 
@@ -272,7 +275,10 @@ Existing commands also accept `--verify-envelope <id>` to attach to a specific e
272
275
  Failed `remits-cli test run` output includes compact pivots per failed case: duration, trace id,
273
276
  bounded `report(...)` diagnostics, live HTTP signals, and resolved component provenance when the
274
277
  platform returns it. Re-read an existing run with `remits-cli test status --task-id <taskId>` before
275
- rerunning a long suite.
278
+ rerunning a long suite. If the same suite/case flips pass/fail while the resolved component provenance
279
+ signature is unchanged, the CLI prints a nondeterministic signal and records `nondeterministic:true` on
280
+ the `test_run` packet. Treat that as an AI/live-data/flaky-fixture investigation cue, not as proof that
281
+ source changed.
276
282
 
277
283
  Test-run requirements must name the suite/cases they mean:
278
284
 
@@ -281,23 +287,28 @@ Test-run requirements must name the suite/cases they mean:
281
287
  {"id":"one_case", "packetType":"test_run", "suite":"Statement API Flow", "cases":["rejects duplicated fee evidence"]}
282
288
  ```
283
289
 
284
- `verify start` and `verify manifest` warn when a requirement has a `packetType` but no discriminator.
285
- The evaluator treats pending async tool packets as pending, not passing; a later `tool status` packet is
286
- the proof. Tool packets store the response file path, byte size and hash rather than copying the whole
287
- tool payload into the envelope.
288
-
289
- Use `remits-cli verify list` to review open envelopes for the account without SQL. If a newer envelope
290
- replaces an older one, use `verify supersede`; if a duplicate or abandoned attempt should no longer read
291
- as live work, use `verify abandon`. Both keep the evidence history.
290
+ A suite-only Test requirement is treated as `allCases:true`. A category-only Test requirement matches only
291
+ a run carrying that exact evidence category, or the case whose name the category names; unrelated Test
292
+ runs cannot replace it. `verify start` and `verify manifest` warn when a requirement has a `packetType` but
293
+ no discriminator. The evaluator treats pending async tool packets as pending, not passing; a later
294
+ `tool status` packet is the proof. Tool packets store the response file path, byte size and hash rather
295
+ than copying the whole tool payload into the envelope.
296
+
297
+ Use `remits-cli verify list` to review open envelopes for the account without SQL. The list shows stale
298
+ counts, requirements-changed markers, and last packet time. `verify start` warns when another open envelope
299
+ with the same summary/account/world exists; use `verify use <id>` to continue that contract or
300
+ `verify supersede` when a newer envelope replaces it. If a duplicate or abandoned attempt should no longer
301
+ read as live work, use `verify abandon`. Both keep the evidence history. Starting a prod-data envelope
302
+ without required evidence prints a loud no-contract warning; add a manifest unless the envelope is
303
+ intentionally evidence-only.
292
304
 
293
305
  The manifest is the proof contract. It should name the world being exercised: repo account, host,
294
306
  git/component branch, workspace, data mode, source layer, user journeys, artifacts and hashes, and the
295
307
  evidence categories required before the final response may claim the work is done. If the manifest
296
308
  cannot be written because the workflow is ambiguous, ask before implementation. For machine-gated report
297
309
  requirements, prefer structured entries like `{"id":"actual_upload","packetType":"browser_step",
298
- "category":"browser_session.actual_upload"}`. Plain strings work when they match a packet type or an
299
- evidence category, but structured entries are clearer when several agents collect packets for one
300
- envelope.
310
+ "category":"browser_session.actual_upload"}`. Plain strings work for evidence categories, but do not use
311
+ plain `"test_run"` as acceptance: it cannot say which Test or case proved the behavior.
301
312
 
302
313
  Common packet meanings:
303
314
 
@@ -318,14 +329,21 @@ Final claims should come from `remits-cli verify report`. Treat `Verified` as th
318
329
  the report lists it under `Verified`. If the report says `partially_verified`, stale, or missing evidence,
319
330
  say that plainly instead of widening the claim. In particular, staged proof is not committed variant/trunk
320
331
  proof, a token is not browser proof, and a direct DOM or Alpine state mutation is not the same as a user
321
- click/upload/reload flow.
332
+ action.
333
+
334
+ Evidence from the wrong world is excluded before it can satisfy a requirement. When the manifest declares
335
+ fields such as `repoAccountId`, `gitBranch`, `componentBranch`, `workspace`, or `dataMode`, the report names
336
+ the mismatch (`expected X got Y`) under missing evidence. Later stage/sync/source facts can stale earlier
337
+ packets; explicit invalidations stale only packets collected before the invalidation, so a fresh rerun can
338
+ verify the same requirement again.
322
339
 
323
340
  For tests specifically:
324
341
  - If `--data-mode` is omitted, `remits-cli test run` uses `test` and sends `dataModeSource:"cliDefault"`.
325
342
  An explicit `--data-mode prod` sends `dataModeSource:"explicitFlag"` so production test runs are
326
343
  auditable from the server status payload even when the original terminal history is gone.
327
- - `--names` is `|`-delimited (a comma still splits a single value) and may be repeated; an unmatched
328
- selector fails the run instead of reporting zero cases as success.
344
+ - `--names` is `|`-delimited and may be repeated. A selector containing commas matches a case with that
345
+ exact name first, else its comma-separated parts; an unmatched selector fails the run instead of
346
+ reporting zero cases as success.
329
347
  - `--as-account <ID>` runs AS a descendant subscriber so its edge selects the component branch
330
348
  (*"what does customer X get?"*); `--variant-branch <name>` probes a branch from any checkout
331
349
  (*"what does branch Y look like?"*), and `--variant-branch none` forces production/subscription semantics.
@@ -160,7 +160,10 @@ TestMode still uses the committed DB source. Staging remains a dev/verification
160
160
  - **Inspect another lane without impersonating it:** `remits-cli components lanes` lists every indexed
161
161
  lane on the account; `remits-cli components entries --lane-id <id>` reads the authoritative staged
162
162
  files for one lane. These are read-only review surfaces. They do not switch your workspace, clear
163
- anything, or change what your own test/token/tool runs resolve.
163
+ anything, or change what your own test/token/tool runs resolve. Use
164
+ `remits-cli components lanes --wait-change [--lane-id ID] [--timeout 600]` when you need to wait for a
165
+ sibling lane to move; it watches the lane registry's `updatedAtMs`/content hash instead of making you
166
+ poll in a loop.
164
167
  - **Clear only your own lane:** `remits-cli components clear` removes entries when you intentionally want
165
168
  to fall back to DB source. `--all` is scoped to the command's account/user/branch/workspace lane, not
166
169
  every lane another agent may be using.
@@ -209,7 +212,9 @@ commit write `ComponentVariant` overlays for a branch nobody subscribes to).
209
212
  account's lane context in their responses. A shared current lane is printed loudly; sibling lanes are
210
213
  listed when they matter.
211
214
  - `remits-cli components lanes` is the review view across users/branches/workspaces, and
212
- `remits-cli components entries --lane-id <id>` is the drill-down into actual staged files.
215
+ `remits-cli components entries --lane-id <id>` is the drill-down into actual staged files. When you are
216
+ coordinating with another agent, prefer `components lanes --wait-change --lane-id <id>` to repeated
217
+ status checks.
213
218
  - `remits-cli components clear --all` is scoped to YOUR lane and never touches another agent's.
214
219
  - In lane rows, `currentLane` (also `mine`) marks THIS command's lane; `ownedByCaller` marks every lane
215
220
  staged by your CLI user — your other clones' agents included.
@@ -104,9 +104,13 @@ For Test requirements, be specific enough for the evaluator to know what a pass
104
104
  {"id":"duplicate_fee_case", "packetType":"test_run", "suite":"Statement API Flow", "cases":["rejects duplicated fee evidence"]}
105
105
  ```
106
106
 
107
- A requirement that says only `{"packetType":"test_run"}` is intentionally only a warning-worthy sketch:
108
- it will not turn a random Test packet into acceptance. Full-suite runs emit suite and passed-case
109
- categories automatically, so case-level requirements can be satisfied by a real full run.
107
+ A suite-only Test requirement is treated as `allCases:true`. A category-only Test requirement matches only
108
+ a run carrying that exact evidence category, or the case whose name the category names. A requirement that
109
+ says only `{"packetType":"test_run"}` is intentionally only a warning-worthy sketch: it will not turn a
110
+ random Test packet into acceptance. Full-suite runs emit suite and passed-case categories automatically,
111
+ so case-level requirements can be satisfied by a real full run. The evaluator excludes packets collected
112
+ in the wrong account/data lane/git branch/component branch/workspace before satisfying requirements, and
113
+ pending async packets do not count until a result packet arrives.
110
114
 
111
115
  ## Development Workflow
112
116