@remits/remits-cli 0.1.128 → 0.1.130

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
@@ -4,18 +4,18 @@
4
4
  ## Table of Contents
5
5
 
6
6
  - L22 Runtime Bootstrap And Shared State
7
- - L116 Sessions, Account Resolution, And Production Guards
8
- - L595 Local State, Workspaces, And Verification Evidence
9
- - L1399 Account Repos, Guide Sync, And Platform Repo
10
- - L2057 Component Discovery And HTTP Logging
11
- - L2549 Skill Delivery And TOC Resolution
12
- - L2859 Auth And Component Staging
13
- - L3457 Component Summaries, Status, And Sync Gates
14
- - L5323 Branches, Promotion, Commit, And Test Runs
15
- - L6562 Tokens, Tools, Verification, And Config
16
- - L7478 Service Dashboard And WebSocket Listener
17
- - L9573 Agent And Ticket Workflows
18
- - L12631 Help, Auto Update, And Command Dispatch
7
+ - L119 Sessions, Account Resolution, And Production Guards
8
+ - L603 Local State, Workspaces, And Verification Evidence
9
+ - L2018 Account Repos, Guide Sync, And Platform Repo
10
+ - L2676 Component Discovery And HTTP Logging
11
+ - L3252 Skill Delivery And TOC Resolution
12
+ - L3562 Auth And Component Staging
13
+ - L4171 Component Summaries, Status, And Sync Gates
14
+ - L6204 Branches, Promotion, Commit, And Test Runs
15
+ - L7813 Tokens, Tools, Verification, And Config
16
+ - L8943 Service Dashboard And WebSocket Listener
17
+ - L11059 Agent And Ticket Workflows
18
+ - L14117 Help, Auto Update, And Command Dispatch
19
19
  */
20
20
 
21
21
  /*
@@ -102,9 +102,12 @@ const ACCOUNT_SCAN_EXCLUDE_DIRS = new Set([
102
102
  'tmp'
103
103
  ]);
104
104
 
105
+ const LOCAL_ACTOR_MAX_LENGTH = 64;
106
+ let LOCAL_ACTOR_CONTEXT = null;
107
+
105
108
  // Flags that are legitimately repeatable accumulate into an array instead of last-wins. Every other
106
109
  // flag keeps last-wins so existing callers are unaffected.
107
- const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved', 'names']);
110
+ const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved', 'names', 'tag', 'tags', 'key', 'keys', 'case-key']);
108
111
 
109
112
  // `-m "msg"` is how git spells a commit message, and agents type it from habit. Before this the parser took a
110
113
  // single-dash token as the VALUE of whatever flag preceded it: `--json -m "x"` silently turned JSON mode
@@ -405,6 +408,7 @@ function resolveSessionIdentity(cwd, flags = {}) {
405
408
  branchName,
406
409
  workspace,
407
410
  workspaceSource: workspaceSource(cwd, flags),
411
+ localState: localStateSummary(cwd, flags),
408
412
  dataMode,
409
413
  // `test run` resolves its lane from the FLAG ONLY (testRunCommand), never the stored session, so that
410
414
  // an account whose session is parked on prod cannot have a test run silently follow it. Report both
@@ -433,6 +437,8 @@ function printSessionIdentity(identity, options = {}) {
433
437
  console.log('Data mode (test run):', identity.testRunDataMode || DEFAULT_DATA_MODE,
434
438
  '[' + (identity.testRunDataModeSource || 'cliDefault') + ']');
435
439
  console.log('Base URL:', identity.baseUrl);
440
+ console.log('Local actor:', identity.localState.actor + ' (from ' + identity.localState.actorSource + ')');
441
+ console.log('Local state:', identity.localState.stateDir);
436
442
  return;
437
443
  }
438
444
 
@@ -456,6 +462,8 @@ function printSessionIdentity(identity, options = {}) {
456
462
  console.log(' Branch:', identity.branchName);
457
463
  console.log(' Workspace:', (identity.workspace || 'none (shared default lane)') +
458
464
  (identity.workspaceSource ? ' [' + identity.workspaceSource + ']' : ''));
465
+ console.log(' Local actor:', identity.localState.actor + ' (from ' + identity.localState.actorSource + ')');
466
+ console.log(' Local state:', identity.localState.stateDir);
459
467
  console.log(' Data mode:', identity.dataMode, '(applies to tool/tools/token)');
460
468
  // `test run` deliberately ignores the stored session lane and defaults to test, so a session sitting on
461
469
  // prod would otherwise read as "prod" here while the next test run went to the test lane. Say so rather
@@ -603,16 +611,316 @@ function ensureDir(dirPath) {
603
611
 
604
612
  function localStatePaths(cwd) {
605
613
  const base = path.join(cwd, '.remits-cli');
614
+ const actor = resolveLocalActor();
615
+ const actorDir = path.join(base, 'actors', actor.id);
616
+ const legacyVerificationDir = path.join(base, 'verification');
606
617
  return {
607
618
  base,
608
- toolsDir: path.join(base, 'tools'),
609
- sessionsDir: path.join(base, 'sessions'),
610
- toolResponsesDir: path.join(base, 'tool-responses'),
611
- verificationDir: path.join(base, 'verification'),
612
- activeVerificationFile: path.join(base, 'verification', 'active'),
613
- activeVerificationContextsFile: path.join(base, 'verification', 'active-contexts.json'),
614
- currentSessionFile: path.join(base, 'current-session.txt'),
615
- workspaceFile: path.join(base, 'workspace')
619
+ actor,
620
+ actorsDir: path.join(base, 'actors'),
621
+ actorDir,
622
+ sharedDir: path.join(base, 'shared'),
623
+ toolsDir: path.join(base, 'shared', 'tools'),
624
+ sessionsDir: path.join(actorDir, 'sessions'),
625
+ toolResponsesDir: path.join(actorDir, 'tool-responses'),
626
+ verificationDir: path.join(actorDir, 'verification'),
627
+ diagnosticsDir: path.join(actorDir, 'diagnostics'),
628
+ activeVerificationFile: path.join(actorDir, 'verification', 'active'),
629
+ activeVerificationContextsFile: path.join(actorDir, 'verification', 'active-contexts.json'),
630
+ currentSessionFile: path.join(actorDir, 'current-session.txt'),
631
+ workspaceFile: path.join(base, 'workspace'),
632
+ stateVersionFile: path.join(base, 'state-version.json'),
633
+ legacy: {
634
+ toolsDir: path.join(base, 'tools'),
635
+ sessionsDir: path.join(base, 'sessions'),
636
+ toolResponsesDir: path.join(base, 'tool-responses'),
637
+ verificationDir: legacyVerificationDir,
638
+ activeVerificationFile: path.join(legacyVerificationDir, 'active'),
639
+ activeVerificationContextsFile: path.join(legacyVerificationDir, 'active-contexts.json'),
640
+ currentSessionFile: path.join(base, 'current-session.txt')
641
+ }
642
+ };
643
+ }
644
+
645
+ function normalizeLocalActor(value) {
646
+ if (value === undefined || value === null) return null;
647
+ const trimmed = String(value).trim();
648
+ if (!trimmed) return null;
649
+ let normalized = trimmed
650
+ .toLowerCase()
651
+ .replace(/[^a-z0-9._-]/g, '-')
652
+ .replace(/-+/g, '-')
653
+ .replace(/^[-.]+/, '')
654
+ .replace(/[-.]+$/, '');
655
+ if (normalized.length > LOCAL_ACTOR_MAX_LENGTH) {
656
+ normalized = normalized.slice(0, LOCAL_ACTOR_MAX_LENGTH).replace(/[-.]+$/, '');
657
+ }
658
+ return normalized || null;
659
+ }
660
+
661
+ // The generated fallback id must be STABLE for a given user and checkout. It used to carry process.pid, so every
662
+ // single CLI invocation minted a fresh actor directory: 85 of them accumulated in one account repo, each holding a
663
+ // partial copy of the session and tool-response state, the "other actor state exists" warning grew to a ~50-name
664
+ // wall, and — because the active-verification pointer lives in the actor directory — no invocation could ever see
665
+ // the envelope the previous one opened, so everything silently fell back to the legacy flat state.
666
+ // Genuine concurrent agents still isolate themselves explicitly, with --actor / --local-agent / REMITS_AGENT_ID
667
+ // (which `agent serve` exports into each child), and that path is unchanged.
668
+ function generatedLocalActorId() {
669
+ const base = 'local-' + os.userInfo().username + '-' + path.basename(process.cwd());
670
+ return normalizeLocalActor(base) || 'local-default';
671
+ }
672
+
673
+ function resolveLocalActor(flags = {}) {
674
+ if (LOCAL_ACTOR_CONTEXT && LOCAL_ACTOR_CONTEXT.id) return LOCAL_ACTOR_CONTEXT;
675
+ const explicit = flags['local-agent'] || flags.localAgent || flags['actor'] || flags.actor;
676
+ const envValue = process.env.REMITS_AGENT_ID || process.env.REMITS_LOCAL_AGENT_ID;
677
+ const explicitId = normalizeLocalActor(explicit);
678
+ if (explicitId) return { id: explicitId, source: flags['local-agent'] || flags.localAgent ? '--local-agent' : '--actor' };
679
+ const envId = normalizeLocalActor(envValue);
680
+ if (envId) return { id: envId, source: process.env.REMITS_AGENT_ID ? 'REMITS_AGENT_ID' : 'REMITS_LOCAL_AGENT_ID' };
681
+ return { id: generatedLocalActorId(), source: 'generated from process and checkout' };
682
+ }
683
+
684
+ function configureLocalActor(flags = {}) {
685
+ const explicit = flags['local-agent'] || flags.localAgent || flags['actor'] || flags.actor;
686
+ const explicitId = normalizeLocalActor(explicit);
687
+ if (explicitId) {
688
+ LOCAL_ACTOR_CONTEXT = { id: explicitId, source: flags['local-agent'] || flags.localAgent ? '--local-agent' : '--actor' };
689
+ } else if (!LOCAL_ACTOR_CONTEXT) {
690
+ LOCAL_ACTOR_CONTEXT = resolveLocalActor({});
691
+ }
692
+ return LOCAL_ACTOR_CONTEXT;
693
+ }
694
+
695
+ function localActorId(flags = {}) {
696
+ return resolveLocalActor(flags).id;
697
+ }
698
+
699
+ function printLocalActor(cwd, flags = {}) {
700
+ const paths = localStatePaths(cwd);
701
+ console.log('Local actor:', paths.actor.id + ' (from ' + paths.actor.source + ')');
702
+ console.log('Local state:', paths.actorDir);
703
+ }
704
+
705
+ function listActorDirectories(cwd) {
706
+ const paths = localStatePaths(cwd);
707
+ try {
708
+ if (!fs.existsSync(paths.actorsDir)) return [];
709
+ return fs.readdirSync(paths.actorsDir, { withFileTypes: true })
710
+ .filter((entry) => entry.isDirectory())
711
+ .map((entry) => {
712
+ const actorDir = path.join(paths.actorsDir, entry.name);
713
+ const stat = fileStat(actorDir);
714
+ return {
715
+ actor: entry.name,
716
+ path: actorDir,
717
+ current: entry.name === paths.actor.id,
718
+ updatedAt: stat ? stat.mtime.toISOString() : null
719
+ };
720
+ })
721
+ .sort((a, b) => String(a.actor).localeCompare(String(b.actor)));
722
+ } catch (_) {
723
+ return [];
724
+ }
725
+ }
726
+
727
+ function gitTrackedRemitsCliFiles(cwd) {
728
+ try {
729
+ const output = runGit(cwd, 'git ls-files .remits-cli');
730
+ return output ? output.split(/\r?\n/).filter(Boolean) : [];
731
+ } catch (_) {
732
+ return [];
733
+ }
734
+ }
735
+
736
+ function fileRowsUnder(dir, suffixPattern = null, max = 50) {
737
+ const rows = [];
738
+ function walk(current) {
739
+ if (rows.length >= max || !fs.existsSync(current)) return;
740
+ let entries = [];
741
+ try {
742
+ entries = fs.readdirSync(current, { withFileTypes: true });
743
+ } catch (_) {
744
+ return;
745
+ }
746
+ entries.forEach((entry) => {
747
+ if (rows.length >= max) return;
748
+ const entryPath = path.join(current, entry.name);
749
+ if (entry.isDirectory()) {
750
+ walk(entryPath);
751
+ } else if (!suffixPattern || suffixPattern.test(entry.name)) {
752
+ const stat = fileStat(entryPath);
753
+ rows.push({
754
+ path: entryPath,
755
+ size: stat ? stat.size : 0,
756
+ updatedAt: stat ? stat.mtime.toISOString() : null
757
+ });
758
+ }
759
+ });
760
+ }
761
+ walk(dir);
762
+ return rows;
763
+ }
764
+
765
+ function likelyRawPayloadSessionLog(file) {
766
+ const entries = readJsonLinesFile(file, 120);
767
+ return entries.some((entry) => {
768
+ if (!entry || typeof entry !== 'object') return false;
769
+ if (entry.unsafePayloadLogging === true) return true;
770
+ const request = entry.request;
771
+ if (!request || typeof request !== 'object') return false;
772
+ const text = JSON.stringify(request);
773
+ return /statementText|ocrText|messages|actionInput|source|html|schema/i.test(text) &&
774
+ text.length > 2000;
775
+ });
776
+ }
777
+
778
+ function localStateReport(cwd, flags = {}) {
779
+ const paths = ensureLocalState(cwd);
780
+ const actors = listActorDirectories(cwd);
781
+ const legacySessionLogs = fileRowsUnder(paths.legacy.sessionsDir, /\.jsonl$/);
782
+ const actorSessionLogs = fileRowsUnder(paths.sessionsDir, /\.jsonl$/);
783
+ const legacyToolResponses = fileRowsUnder(paths.legacy.toolResponsesDir, /\.json$/);
784
+ const actorToolResponses = fileRowsUnder(paths.toolResponsesDir, /\.json$/);
785
+ const largest = actorSessionLogs.concat(legacySessionLogs, actorToolResponses, legacyToolResponses)
786
+ .sort((a, b) => b.size - a.size)
787
+ .slice(0, 10);
788
+ return {
789
+ cwd,
790
+ actor: paths.actor,
791
+ activeActorStateDir: paths.actorDir,
792
+ workspace: resolveWorkspace(cwd, flags),
793
+ workspaceSource: workspaceSource(cwd, flags),
794
+ workspaceFile: paths.workspaceFile,
795
+ trackedFiles: gitTrackedRemitsCliFiles(cwd),
796
+ actors,
797
+ foreignActors: actors.filter((actor) => !actor.current),
798
+ legacy: {
799
+ currentSessionFile: paths.legacy.currentSessionFile,
800
+ currentSessionExists: fs.existsSync(paths.legacy.currentSessionFile),
801
+ sessionLogs: legacySessionLogs,
802
+ toolResponses: legacyToolResponses,
803
+ likelyRawPayloadLogs: legacySessionLogs.filter((row) => likelyRawPayloadSessionLog(row.path))
804
+ },
805
+ current: {
806
+ currentSessionFile: paths.currentSessionFile,
807
+ sessionLogs: actorSessionLogs,
808
+ toolResponses: actorToolResponses,
809
+ likelyRawPayloadLogs: actorSessionLogs.filter((row) => likelyRawPayloadSessionLog(row.path))
810
+ },
811
+ largestFiles: largest
812
+ };
813
+ }
814
+
815
+ const FOREIGN_ACTOR_RECENT_MS = 7 * 24 * 60 * 60 * 1000;
816
+
817
+ // Directories left by the old per-process generated id ('session-<pid>-<user>-<checkout>'). Nothing mints that
818
+ // shape any more, so however recently one was written it is debris from a finished process, never a live agent:
819
+ // it is prunable regardless of age, which matters because the defect produced dozens of them in a single day.
820
+ const LEGACY_PER_PROCESS_ACTOR = /^session-\d+-/;
821
+
822
+ function isDisposableActor(actor, cutoff) {
823
+ if (!actor || actor.current) return false;
824
+ if (LEGACY_PER_PROCESS_ACTOR.test(String(actor.actor))) return true;
825
+ return !(actor.updatedAt && Date.parse(actor.updatedAt) >= cutoff);
826
+ }
827
+
828
+ // Remove actor state directories that are neither the current actor nor recently active. Only ever touches
829
+ // directories under .remits-cli/actors, and never the active one, so the worst case is discarding old session
830
+ // logs and tool responses that nothing reads.
831
+ function pruneStaleActorDirectories(cwd) {
832
+ const paths = localStatePaths(cwd);
833
+ const cutoff = Date.now() - FOREIGN_ACTOR_RECENT_MS;
834
+ const removed = [];
835
+ const failed = [];
836
+ let kept = 0;
837
+ listActorDirectories(cwd).forEach((actor) => {
838
+ if (!isDisposableActor(actor, cutoff)) {
839
+ kept += 1;
840
+ return;
841
+ }
842
+ if (path.dirname(actor.path) !== paths.actorsDir) return;
843
+ try {
844
+ fs.rmSync(actor.path, { recursive: true, force: true });
845
+ removed.push(actor.actor);
846
+ } catch (_) {
847
+ failed.push(actor.actor);
848
+ }
849
+ });
850
+ return { removed, failed, kept };
851
+ }
852
+
853
+ function localStateWarnings(cwd, flags = {}) {
854
+ const report = localStateReport(cwd, flags);
855
+ const warnings = [];
856
+ if (!report.workspace) {
857
+ warnings.push('Using the shared/default workspace lane. Run `remits-cli workspace use --auto` for isolated staging.');
858
+ }
859
+ if (report.trackedFiles.length) {
860
+ warnings.push('.remits-cli is tracked by git (' + report.trackedFiles.length + ' file(s)); untrack it before committing.');
861
+ }
862
+ if (report.actor.source && report.actor.source.startsWith('generated') && report.foreignActors.length) {
863
+ // Only RECENTLY active other actors are a signal. Every generated actor leaves a directory behind forever,
864
+ // so listing them all printed a ~50-name wall on every command that drowned the warnings worth reading.
865
+ const cutoff = Date.now() - FOREIGN_ACTOR_RECENT_MS;
866
+ const recent = report.foreignActors
867
+ .filter((a) => !isDisposableActor(a, cutoff))
868
+ .sort((a, b) => Date.parse(b.updatedAt || 0) - Date.parse(a.updatedAt || 0));
869
+ const shown = recent.slice(0, 3).map((a) => a.actor);
870
+ const stale = report.foreignActors.length - recent.length;
871
+ const setOne = 'Name this one with --actor <name> (or REMITS_AGENT_ID) if another agent shares this checkout.';
872
+ const pruneHint = stale
873
+ ? stale + ' spent actor dir(s) (idle, or left behind by finished processes) can be cleared with `remits-cli doctor local-state --prune-actors`.'
874
+ : '';
875
+ if (shown.length) {
876
+ warnings.push('Other actor state is live here: ' + shown.join(', ') +
877
+ (recent.length > shown.length ? ' (+' + (recent.length - shown.length) + ' more)' : '') + '. ' + setOne +
878
+ (pruneHint ? ' ' + pruneHint : ''));
879
+ } else if (pruneHint) {
880
+ warnings.push(pruneHint);
881
+ }
882
+ }
883
+ if (report.legacy.currentSessionExists || report.legacy.sessionLogs.length || report.legacy.toolResponses.length) {
884
+ warnings.push('Legacy flat .remits-cli state exists; new writes use actor-scoped state and legacy files are read only as fallback.');
885
+ }
886
+ if (report.current.likelyRawPayloadLogs.length || report.legacy.likelyRawPayloadLogs.length) {
887
+ warnings.push('At least one session log appears to contain raw or unsafe payload-shaped fields.');
888
+ }
889
+ return { report, warnings };
890
+ }
891
+
892
+ function printLocalStateWarnings(cwd, flags = {}) {
893
+ const { warnings } = localStateWarnings(cwd, flags);
894
+ if (!warnings.length) return;
895
+ console.log('Local state warnings:');
896
+ warnings.forEach((warning) => console.log(' - ' + warning));
897
+ }
898
+
899
+ function printLocalCommandContext(cwd, flags = {}, context = {}) {
900
+ const paths = localStatePaths(cwd);
901
+ const branchName = context.branchName || flags.branch || safeGitValue(cwd, 'git rev-parse --abbrev-ref HEAD') || 'unknown';
902
+ const workspace = context.workspace !== undefined ? context.workspace : resolveWorkspace(cwd, flags);
903
+ const dataMode = context.dataMode || null;
904
+ const accountId = context.accountId || null;
905
+ console.log('Local actor:', paths.actor.id + ' (from ' + paths.actor.source + ')');
906
+ console.log('Local state:', paths.actorDir);
907
+ const pieces = [];
908
+ if (accountId) pieces.push('account=' + accountId);
909
+ pieces.push('branch=' + branchName);
910
+ pieces.push('workspace=' + (workspace || 'shared'));
911
+ if (dataMode) pieces.push('dataMode=' + dataMode);
912
+ console.log('Command context:', pieces.join(', '));
913
+ }
914
+
915
+ function localStateSummary(cwd, flags = {}) {
916
+ const paths = localStatePaths(cwd);
917
+ return {
918
+ actor: paths.actor.id,
919
+ actorSource: paths.actor.source,
920
+ stateDir: paths.actorDir,
921
+ workspace: resolveWorkspace(cwd, flags),
922
+ workspaceSource: workspaceSource(cwd, flags),
923
+ sharedWorkspace: !resolveWorkspace(cwd, flags)
616
924
  };
617
925
  }
618
926
 
@@ -713,10 +1021,21 @@ function printStagingLane(branchName, workspace, source) {
713
1021
  function ensureLocalState(cwd) {
714
1022
  const paths = localStatePaths(cwd);
715
1023
  ensureDir(paths.base);
1024
+ ensureDir(paths.actorsDir);
1025
+ ensureDir(paths.actorDir);
1026
+ ensureDir(paths.sharedDir);
716
1027
  ensureDir(paths.toolsDir);
717
1028
  ensureDir(paths.sessionsDir);
718
1029
  ensureDir(paths.toolResponsesDir);
719
1030
  ensureDir(paths.verificationDir);
1031
+ ensureDir(paths.diagnosticsDir);
1032
+ if (!fs.existsSync(paths.stateVersionFile)) {
1033
+ atomicWriteFile(paths.stateVersionFile, JSON.stringify({
1034
+ version: 2,
1035
+ shape: 'actor-scoped',
1036
+ updatedAt: new Date().toISOString()
1037
+ }, null, 2));
1038
+ }
720
1039
  return paths;
721
1040
  }
722
1041
 
@@ -740,8 +1059,14 @@ function sessionJsonlFile(cwd) {
740
1059
  }
741
1060
 
742
1061
  const CONTENT_FIELDS = new Set(['source', 'html', 'javascript', 'css', 'schema', 'inputSchema', 'previewData', 'messages']);
1062
+ const LARGE_LOG_STRING_BYTES = 256;
1063
+ const MAX_LOG_SUMMARY_DEPTH = 2;
743
1064
  const SECRET_LOG_FIELDS = new Set([
744
1065
  'token',
1066
+ 'embedtokenkey',
1067
+ 'embeddableurl',
1068
+ 'liveurl',
1069
+ 'shortbasepath',
745
1070
  'tokeninput',
746
1071
  'tokenkey',
747
1072
  'authtoken',
@@ -751,6 +1076,26 @@ const SECRET_LOG_FIELDS = new Set([
751
1076
  'x-remits-token',
752
1077
  'authorization'
753
1078
  ]);
1079
+ const CONTENT_LOG_FIELDS = new Set([
1080
+ 'actioninput',
1081
+ 'content',
1082
+ 'css',
1083
+ 'file',
1084
+ 'filecontents',
1085
+ 'html',
1086
+ 'input',
1087
+ 'inputschema',
1088
+ 'javascript',
1089
+ 'messages',
1090
+ 'ocrtext',
1091
+ 'payload',
1092
+ 'previewdata',
1093
+ 'prompt',
1094
+ 'schema',
1095
+ 'source',
1096
+ 'statementtext',
1097
+ 'text'
1098
+ ]);
754
1099
 
755
1100
  function sanitizeForLog(value) {
756
1101
  if (value === null || value === undefined) {
@@ -775,6 +1120,173 @@ function sanitizeForLog(value) {
775
1120
  return out;
776
1121
  }
777
1122
 
1123
+ function estimatedJsonBytes(value) {
1124
+ try {
1125
+ return Buffer.byteLength(JSON.stringify(value));
1126
+ } catch (_) {
1127
+ return Buffer.byteLength(String(value));
1128
+ }
1129
+ }
1130
+
1131
+ function safeHashValue(value) {
1132
+ try {
1133
+ return sha256(stableStringify(value));
1134
+ } catch (_) {
1135
+ return sha256(String(value));
1136
+ }
1137
+ }
1138
+
1139
+ function fieldLooksSensitive(key) {
1140
+ return SECRET_LOG_FIELDS.has(String(key || '').toLowerCase());
1141
+ }
1142
+
1143
+ function fieldLooksContent(key) {
1144
+ return CONTENT_LOG_FIELDS.has(String(key || '').toLowerCase());
1145
+ }
1146
+
1147
+ function summarizeLogValue(value, key = null, state = { nestedKeyCount: 0, redactions: [] }, depth = 0) {
1148
+ const keyText = key == null ? '' : String(key);
1149
+ const nestedPayloadValue = depth > 0;
1150
+ if (fieldLooksSensitive(keyText)) {
1151
+ state.redactions.push(keyText + ' redacted');
1152
+ return { type: 'redacted', redacted: true };
1153
+ }
1154
+ if (value === null || value === undefined) {
1155
+ return { type: value === null ? 'null' : 'undefined' };
1156
+ }
1157
+ if (typeof value === 'string') {
1158
+ const bytes = Buffer.byteLength(value);
1159
+ if (nestedPayloadValue || fieldLooksContent(keyText) || bytes > LARGE_LOG_STRING_BYTES) {
1160
+ state.redactions.push((keyText || 'string') + ' omitted by default');
1161
+ return { type: 'string', bytes, sha256: sha256(value), omitted: true };
1162
+ }
1163
+ return { type: 'string', bytes, value };
1164
+ }
1165
+ if (typeof value === 'number' || typeof value === 'boolean') {
1166
+ if (nestedPayloadValue) {
1167
+ state.redactions.push((keyText || typeof value) + ' omitted by default');
1168
+ return { type: typeof value, sha256: safeHashValue(value), omitted: true };
1169
+ }
1170
+ return { type: typeof value, value };
1171
+ }
1172
+ if (Array.isArray(value)) {
1173
+ if (depth >= MAX_LOG_SUMMARY_DEPTH) {
1174
+ state.redactions.push((keyText || 'array') + ' nested array omitted by default');
1175
+ return {
1176
+ type: 'array',
1177
+ length: value.length,
1178
+ estimatedBytes: estimatedJsonBytes(value),
1179
+ sha256: safeHashValue(value),
1180
+ omitted: true
1181
+ };
1182
+ }
1183
+ const sample = value.slice(0, 5).map((entry) => summarizeLogValue(entry, keyText, state, depth + 1));
1184
+ if (value.length > 5) {
1185
+ state.redactions.push((keyText || 'array') + ' sample capped at 5 of ' + value.length);
1186
+ }
1187
+ return {
1188
+ type: 'array',
1189
+ length: value.length,
1190
+ estimatedBytes: estimatedJsonBytes(value),
1191
+ sha256: safeHashValue(value),
1192
+ sample
1193
+ };
1194
+ }
1195
+ if (typeof value === 'object') {
1196
+ const keys = Object.keys(value).sort();
1197
+ state.nestedKeyCount += keys.length;
1198
+ if (depth >= MAX_LOG_SUMMARY_DEPTH) {
1199
+ state.redactions.push((keyText || 'object') + ' nested object omitted by default');
1200
+ return {
1201
+ type: 'object',
1202
+ keyCount: keys.length,
1203
+ keys,
1204
+ estimatedBytes: estimatedJsonBytes(value),
1205
+ sha256: safeHashValue(value),
1206
+ omitted: true
1207
+ };
1208
+ }
1209
+ const fields = {};
1210
+ keys.slice(0, 50).forEach((childKey) => {
1211
+ fields[childKey] = summarizeLogValue(value[childKey], childKey, state, depth + 1);
1212
+ });
1213
+ if (keys.length > 50) {
1214
+ state.redactions.push((keyText || 'object') + ' keys capped at 50 of ' + keys.length);
1215
+ }
1216
+ return {
1217
+ type: 'object',
1218
+ keyCount: keys.length,
1219
+ keys,
1220
+ estimatedBytes: estimatedJsonBytes(value),
1221
+ sha256: safeHashValue(value),
1222
+ fields
1223
+ };
1224
+ }
1225
+ return { type: typeof value, bytes: estimatedJsonBytes(value), sha256: safeHashValue(value), omitted: true };
1226
+ }
1227
+
1228
+ function summarizePayloadFieldsForLog(payload, defaultRedaction) {
1229
+ const input = payload && typeof payload === 'object' ? payload : { value: payload };
1230
+ const state = { nestedKeyCount: 0, redactions: [] };
1231
+ const topLevelKeys = Object.keys(input).sort();
1232
+ const fieldSummary = {};
1233
+ topLevelKeys.forEach((key) => {
1234
+ fieldSummary[key] = summarizeLogValue(input[key], key, state);
1235
+ });
1236
+ if (state.redactions.length === 0 && defaultRedaction) {
1237
+ state.redactions.push(defaultRedaction);
1238
+ }
1239
+ return { input, state, topLevelKeys, fieldSummary };
1240
+ }
1241
+
1242
+ function summarizeRequestForLog(payload = {}) {
1243
+ const { input, state, topLevelKeys, fieldSummary } = summarizePayloadFieldsForLog(
1244
+ payload,
1245
+ 'payload summarized; nested values omitted by default'
1246
+ );
1247
+ return {
1248
+ accountId: input.accountId !== undefined ? input.accountId : null,
1249
+ dataMode: input.dataMode || null,
1250
+ branchName: input.branchName || null,
1251
+ workspace: input.workspace || null,
1252
+ command: input.command || null,
1253
+ toolName: input.name || input.toolName || null,
1254
+ callId: input.callId || null,
1255
+ topLevelKeys,
1256
+ nestedKeyCount: state.nestedKeyCount,
1257
+ estimatedBytes: estimatedJsonBytes(input),
1258
+ sha256: safeHashValue(input),
1259
+ fields: fieldSummary,
1260
+ redactions: Array.from(new Set(state.redactions))
1261
+ };
1262
+ }
1263
+
1264
+ function summarizeResponseForLog(payload = {}) {
1265
+ const { input, state, topLevelKeys, fieldSummary } = summarizePayloadFieldsForLog(
1266
+ payload,
1267
+ 'response summarized; nested values omitted by default'
1268
+ );
1269
+ return {
1270
+ topLevelKeys,
1271
+ nestedKeyCount: state.nestedKeyCount,
1272
+ estimatedBytes: estimatedJsonBytes(input),
1273
+ sha256: safeHashValue(input),
1274
+ fields: fieldSummary,
1275
+ redactions: Array.from(new Set(state.redactions))
1276
+ };
1277
+ }
1278
+
1279
+ function unsafePayloadLoggingEnabled() {
1280
+ return process.env.REMITS_CLI_UNSAFE_LOG_PAYLOADS === '1';
1281
+ }
1282
+
1283
+ function requestPayloadForSessionLog(payload) {
1284
+ if (unsafePayloadLoggingEnabled()) {
1285
+ return sanitizeForLog(payload);
1286
+ }
1287
+ return undefined;
1288
+ }
1289
+
778
1290
  function appendSessionLog(cwd, entry) {
779
1291
  const file = sessionJsonlFile(cwd);
780
1292
  fs.appendFileSync(file, JSON.stringify(entry) + '\n');
@@ -866,13 +1378,24 @@ function toolEvidenceSummary(data = {}, responseFile) {
866
1378
  function readToolResponseFile(cwd, callId) {
867
1379
  if (!callId) return null;
868
1380
  const paths = ensureLocalState(cwd);
869
- const file = path.join(paths.toolResponsesDir, callId + '.json');
870
- if (!fs.existsSync(file)) return null;
871
- try {
872
- return JSON.parse(fs.readFileSync(file, 'utf8'));
873
- } catch (_) {
874
- return null;
1381
+ const candidates = [
1382
+ path.join(paths.toolResponsesDir, callId + '.json'),
1383
+ path.join(paths.legacy.toolResponsesDir, callId + '.json')
1384
+ ];
1385
+ for (const file of candidates) {
1386
+ if (!fs.existsSync(file)) continue;
1387
+ try {
1388
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
1389
+ if (parsed && typeof parsed === 'object' && file.includes(path.sep + 'tool-responses' + path.sep)) {
1390
+ parsed._localStateFile = file;
1391
+ parsed._legacyLocalState = file.startsWith(paths.legacy.toolResponsesDir);
1392
+ }
1393
+ return parsed;
1394
+ } catch (_) {
1395
+ return null;
1396
+ }
875
1397
  }
1398
+ return null;
876
1399
  }
877
1400
 
878
1401
  function verificationPaths(cwd, envelopeId) {
@@ -898,14 +1421,18 @@ function verificationContextKey(context = {}) {
898
1421
  }
899
1422
 
900
1423
  function readActiveVerificationContexts(cwd) {
901
- const file = localStatePaths(cwd).activeVerificationContextsFile;
902
- if (!fs.existsSync(file)) return {};
903
- try {
904
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
905
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
906
- } catch (_) {
907
- return {};
1424
+ const paths = localStatePaths(cwd);
1425
+ const candidates = [paths.activeVerificationContextsFile, paths.legacy.activeVerificationContextsFile];
1426
+ for (const file of candidates) {
1427
+ if (!fs.existsSync(file)) continue;
1428
+ try {
1429
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
1430
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
1431
+ } catch (_) {
1432
+ return {};
1433
+ }
908
1434
  }
1435
+ return {};
909
1436
  }
910
1437
 
911
1438
  function writeActiveVerificationContexts(cwd, contexts) {
@@ -931,14 +1458,18 @@ function readActiveVerificationEnvelope(cwd, context = null) {
931
1458
  if (typeof entry === 'string') return entry || null;
932
1459
  return null;
933
1460
  }
934
- const file = verificationPaths(cwd).activeFile;
935
- try {
936
- if (!fs.existsSync(file)) return null;
937
- const id = fs.readFileSync(file, 'utf8').trim();
938
- return id || null;
939
- } catch (_) {
940
- return null;
1461
+ const paths = verificationPaths(cwd);
1462
+ const candidates = [paths.activeFile, localStatePaths(cwd).legacy.activeVerificationFile];
1463
+ for (const file of candidates) {
1464
+ try {
1465
+ if (!fs.existsSync(file)) continue;
1466
+ const id = fs.readFileSync(file, 'utf8').trim();
1467
+ return id || null;
1468
+ } catch (_) {
1469
+ return null;
1470
+ }
941
1471
  }
1472
+ return null;
942
1473
  }
943
1474
 
944
1475
  function writeActiveVerificationEnvelope(cwd, envelopeId, context = null) {
@@ -990,29 +1521,36 @@ function writeLocalVerificationEnvelope(cwd, envelope) {
990
1521
 
991
1522
  function readLocalVerificationEnvelope(cwd, envelopeId) {
992
1523
  const paths = verificationPaths(cwd, envelopeId);
993
- if (!fs.existsSync(paths.envelopeFile)) return null;
994
- try {
995
- return JSON.parse(fs.readFileSync(paths.envelopeFile, 'utf8'));
996
- } catch (_) {
997
- return null;
1524
+ const legacyBase = path.join(localStatePaths(cwd).legacy.verificationDir, String(envelopeId));
1525
+ const candidates = [paths.envelopeFile, path.join(legacyBase, 'envelope.json')];
1526
+ for (const file of candidates) {
1527
+ if (!fs.existsSync(file)) continue;
1528
+ try {
1529
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
1530
+ } catch (_) {
1531
+ return null;
1532
+ }
998
1533
  }
1534
+ return null;
999
1535
  }
1000
1536
 
1001
1537
  function readLocalVerificationPackets(cwd, envelopeId, filters = {}) {
1002
1538
  const paths = verificationPaths(cwd, envelopeId);
1539
+ const legacyPacketsDir = path.join(localStatePaths(cwd).legacy.verificationDir, String(envelopeId), 'packets');
1003
1540
  const packets = [];
1004
- if (fs.existsSync(paths.packetsDir)) {
1005
- fs.readdirSync(paths.packetsDir)
1541
+ [paths.packetsDir, legacyPacketsDir].forEach((packetsDir) => {
1542
+ if (!fs.existsSync(packetsDir)) return;
1543
+ fs.readdirSync(packetsDir)
1006
1544
  .filter((name) => name.endsWith('.json'))
1007
1545
  .forEach((name) => {
1008
1546
  try {
1009
- const parsed = JSON.parse(fs.readFileSync(path.join(paths.packetsDir, name), 'utf8'));
1547
+ const parsed = JSON.parse(fs.readFileSync(path.join(packetsDir, name), 'utf8'));
1010
1548
  if (parsed && typeof parsed === 'object') packets.push(parsed);
1011
1549
  } catch (_) {
1012
1550
  // Ignore one malformed local packet; the rest of the envelope remains useful.
1013
1551
  }
1014
1552
  });
1015
- }
1553
+ });
1016
1554
  if (!packets.length) {
1017
1555
  const envelope = readLocalVerificationEnvelope(cwd, envelopeId);
1018
1556
  if (envelope && Array.isArray(envelope.packets)) packets.push(...envelope.packets);
@@ -1107,6 +1645,66 @@ async function postVerificationCommand(api, cwd, session, accountId, command, pa
1107
1645
  return response;
1108
1646
  }
1109
1647
 
1648
+ // The repo/session account a packet was recorded from, stated explicitly in its world. `world.accountId` is the
1649
+ // account the command EXECUTED as, so an --as-account run carried the subscriber there and a manifest's
1650
+ // repoAccountId could never match it. Only fills a missing value; a packet that already knows keeps its own.
1651
+ function stampPacketRepoAccount(packet, accountId) {
1652
+ if (!packet || accountId === undefined || accountId === null || accountId === '') return packet;
1653
+ const repoAccountId = Number(accountId);
1654
+ if (!Number.isFinite(repoAccountId)) return packet;
1655
+ if (!packet.world || typeof packet.world !== 'object') packet.world = {};
1656
+ if (packet.world.repoAccountId === undefined || packet.world.repoAccountId === null) {
1657
+ packet.world.repoAccountId = repoAccountId;
1658
+ }
1659
+ return packet;
1660
+ }
1661
+
1662
+ // A sync packet is evidence of WHAT a sync planned or wrote, not a second copy of the preview. The per-field
1663
+ // trunk/branch text a dry run attaches for the admin diff panel is up to 400 KB per sync, and storing it on
1664
+ // every packet made one busy envelope 9.4 MB — slow enough that `verify show` timed out. Field NAMES
1665
+ // (`changedFields`) stay; the full plan is always one `components sync --dry-run` away.
1666
+ function syncEvidencePayload(sync) {
1667
+ if (!sync || typeof sync !== 'object') return sync;
1668
+ const results = sync.syncResults;
1669
+ if (!results || typeof results !== 'object') return sync;
1670
+ const trimmedResults = {};
1671
+ Object.keys(results).forEach((bucket) => {
1672
+ const entries = results[bucket];
1673
+ trimmedResults[bucket] = Array.isArray(entries)
1674
+ ? entries.map((entry) => {
1675
+ if (!entry || typeof entry !== 'object' || !('fields' in entry)) return entry;
1676
+ const copy = Object.assign({}, entry);
1677
+ delete copy.fields;
1678
+ return copy;
1679
+ })
1680
+ : entries;
1681
+ });
1682
+ return Object.assign({}, sync, { syncResults: trimmedResults });
1683
+ }
1684
+
1685
+ // The world an envelope was STARTED in, read the same way the platform reads it
1686
+ // (VerificationEnvelopeService.envelopeStartWorld / ENVELOPE_START_WORLD_KEYS). Kept field-for-field identical so
1687
+ // the attach-time warning and the verify-time wrong-world verdict can never disagree.
1688
+ function envelopeWorldDrift(envelope, context = {}) {
1689
+ if (!envelope || typeof envelope !== 'object') return [];
1690
+ const source = envelope.source && typeof envelope.source === 'object' ? envelope.source : {};
1691
+ const account = envelope.account && typeof envelope.account === 'object' ? envelope.account : {};
1692
+ const started = {
1693
+ 'repo account': account.repoAccountId != null ? String(account.repoAccountId) : (envelope.accountId != null ? String(envelope.accountId) : null),
1694
+ 'data lane': normalizeDataMode(account.dataMode || source.dataMode) || null,
1695
+ 'git branch': source.gitBranch || source.branchName || null,
1696
+ workspace: normalizeWorkspace(source.workspace) || null
1697
+ };
1698
+ const now = {
1699
+ 'repo account': context.accountId != null ? String(context.accountId) : null,
1700
+ 'data lane': normalizeDataMode(context.dataMode) || null,
1701
+ 'git branch': context.branchName || null,
1702
+ workspace: normalizeWorkspace(context.workspace) || null
1703
+ };
1704
+ return Object.keys(started).filter((key) => started[key] && now[key] && started[key] !== now[key])
1705
+ .map((key) => key + ' ' + started[key] + ' -> ' + now[key]);
1706
+ }
1707
+
1110
1708
  async function appendVerificationPacket(api, cwd, session, accountId, flags, packet, options = {}) {
1111
1709
  const context = activeVerificationContext(cwd, flags, session, accountId);
1112
1710
  const envelopeId = verificationEnvelopeIdForCommand(cwd, flags, context);
@@ -1117,6 +1715,7 @@ async function appendVerificationPacket(api, cwd, session, accountId, flags, pac
1117
1715
  success: true
1118
1716
  }, packet || {});
1119
1717
  finalPacket.envelopeId = envelopeId;
1718
+ stampPacketRepoAccount(finalPacket, accountId);
1120
1719
  writeLocalVerificationPacket(cwd, envelopeId, finalPacket);
1121
1720
  try {
1122
1721
  const response = await postVerificationCommand(api, cwd, session, accountId, 'packet', {
@@ -1125,7 +1724,16 @@ async function appendVerificationPacket(api, cwd, session, accountId, flags, pac
1125
1724
  });
1126
1725
  if (response.packet) writeLocalVerificationPacket(cwd, envelopeId, response.packet);
1127
1726
  if (response.envelope) writeLocalVerificationEnvelope(cwd, response.envelope);
1128
- if (!options.quiet) console.log('Verification envelope:', envelopeId, '(packet ' + finalPacket.type + ' attached)');
1727
+ if (!options.quiet) {
1728
+ console.log('Verification envelope:', envelopeId, '(packet ' + finalPacket.type + ' attached)');
1729
+ // Say it HERE, not only at `verify` time. Evidence attached to an envelope started in a different world
1730
+ // is evidence about a different world, and the reader has to learn that while they can still redo the run.
1731
+ const drift = envelopeWorldDrift(response.envelope, context);
1732
+ if (drift.length) {
1733
+ console.log(' WARNING: this envelope was started in a different world - ' + drift.join('; ') +
1734
+ '. The packet is attached, but it will be reported wrong-world at verify time.');
1735
+ }
1736
+ }
1129
1737
  return response;
1130
1738
  } catch (err) {
1131
1739
  if (!options.quiet) {
@@ -1281,6 +1889,17 @@ function buildCommandWorld(response, fallback = {}) {
1281
1889
  };
1282
1890
  }
1283
1891
 
1892
+ // A variant sync writes overlays on exactly one component branch; say so in the packet world, where a manifest's
1893
+ // componentBranch is compared. Without it no sync_mutation could satisfy a manifest that declares one.
1894
+ function syncPacketWorld(response, world, branchName) {
1895
+ const sync = (response && response.sync) || {};
1896
+ const out = Object.assign({}, world || {});
1897
+ if (sync.mode === 'variant' && !out.componentBranch) {
1898
+ out.componentBranch = sync.branchName || branchName || null;
1899
+ }
1900
+ return out;
1901
+ }
1902
+
1284
1903
  function laneContentHashFrom(response) {
1285
1904
  if (!response) return null;
1286
1905
  const laneSummary = response.laneSummary || (response.staging && response.staging.laneSummary) ||
@@ -2065,11 +2684,75 @@ function resolveSessionContext(cwd, flags) {
2065
2684
  * file there is no id, so the two can disagree (`new_PDFStatement` vs `name: PDF Statement`), and the file
2066
2685
  * identity is the only thing both the payload and the git changed set agree on.
2067
2686
  */
2068
- function withFileKey(target, key) {
2687
+ function withFileKey(target, key, filePaths) {
2069
2688
  Object.defineProperty(target, 'fileKey', { value: key, enumerable: false, configurable: true });
2689
+ if (filePaths) {
2690
+ addHiddenFilePaths(target, filePaths);
2691
+ }
2070
2692
  return target;
2071
2693
  }
2072
2694
 
2695
+ function addHiddenFilePaths(target, filePaths) {
2696
+ if (!target || !filePaths) return target;
2697
+ const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
2698
+ if (!Object.prototype.hasOwnProperty.call(target, 'filePaths')) {
2699
+ Object.defineProperty(target, 'filePaths', { value: [], enumerable: false, configurable: true });
2700
+ }
2701
+ for (const filePath of paths) {
2702
+ const normalized = String(filePath || '').replace(/\\/g, '/');
2703
+ if (normalized && !target.filePaths.includes(normalized)) target.filePaths.push(normalized);
2704
+ }
2705
+ return target;
2706
+ }
2707
+
2708
+ function isRootReadmeComponent(component) {
2709
+ return component &&
2710
+ component.type === 'prompt' &&
2711
+ component.fileKey === 'prompt:name:readme' &&
2712
+ normalizeName(String(component.name || '')).toLowerCase() === 'readme' &&
2713
+ String(component.purpose || '').toUpperCase() === 'README' &&
2714
+ (component.path === 'README.md' || (component.filePaths || []).includes('README.md'));
2715
+ }
2716
+
2717
+ function isLegacyReadmePromptComponent(component) {
2718
+ if (!component || component.type !== 'prompt' ||
2719
+ normalizeName(String(component.name || '')).toLowerCase() !== 'readme') {
2720
+ return false;
2721
+ }
2722
+ return [component.path].concat(component.filePaths || []).some((filePath) =>
2723
+ /^components\/prompts\/[^/]+_README\.md$/i.test(String(filePath || '')));
2724
+ }
2725
+
2726
+ function canonicalizeReadmePromptComponents(components) {
2727
+ const list = Array.isArray(components) ? components : [];
2728
+ const rootReadme = list.find(isRootReadmeComponent);
2729
+ if (!rootReadme) {
2730
+ return { components: list, warnings: [] };
2731
+ }
2732
+
2733
+ const warnings = [];
2734
+ const canonical = [];
2735
+ for (const component of list) {
2736
+ if (component !== rootReadme && isLegacyReadmePromptComponent(component)) {
2737
+ const legacyPaths = [component.path].concat(component.filePaths || [])
2738
+ .filter((filePath) => /^components\/prompts\/[^/]+_README\.md$/i.test(String(filePath || '')))
2739
+ .sort();
2740
+ warnings.push({
2741
+ type: 'legacy-readme-prompt-suppressed',
2742
+ canonicalPath: 'README.md',
2743
+ legacyPaths,
2744
+ id: component.id == null ? null : component.id,
2745
+ name: component.name || 'README',
2746
+ message: 'Skipped legacy README prompt file ' + (legacyPaths.join(', ') || 'components/prompts/*_README.md') +
2747
+ ' because root README.md owns the reserved README prompt identity.'
2748
+ });
2749
+ continue;
2750
+ }
2751
+ canonical.push(component);
2752
+ }
2753
+ return { components: canonical, warnings };
2754
+ }
2755
+
2073
2756
  function collectComponents(cwd) {
2074
2757
  const mapping = {
2075
2758
  schemas: 'schema',
@@ -2121,6 +2804,7 @@ function collectComponents(cwd) {
2121
2804
  }
2122
2805
  const component = byKey.get(key);
2123
2806
  const filePath = path.join(dir, fileName);
2807
+ addHiddenFilePaths(component, path.relative(cwd, filePath).replace(/\\/g, '/'));
2124
2808
  const content = fs.readFileSync(filePath, 'utf8');
2125
2809
 
2126
2810
  if (ext === 'meta') {
@@ -2184,7 +2868,7 @@ function collectComponents(cwd) {
2184
2868
  category: 'default',
2185
2869
  prompt: fs.readFileSync(rootReadme, 'utf8'),
2186
2870
  metadataAuthoritative: true
2187
- }, 'prompt:name:readme');
2871
+ }, 'prompt:name:readme', 'README.md');
2188
2872
  const fingerprint = {};
2189
2873
  Object.keys(component).sort().forEach((key) => {
2190
2874
  if (key !== 'hash') {
@@ -2195,7 +2879,13 @@ function collectComponents(cwd) {
2195
2879
  components.push(component);
2196
2880
  }
2197
2881
 
2198
- return components;
2882
+ const canonicalized = canonicalizeReadmePromptComponents(components);
2883
+ Object.defineProperty(canonicalized.components, 'discoveryWarnings', {
2884
+ value: canonicalized.warnings,
2885
+ enumerable: false,
2886
+ configurable: true
2887
+ });
2888
+ return canonicalized.components;
2199
2889
  }
2200
2890
 
2201
2891
  function componentPathInfo(cwd, filePath) {
@@ -2485,6 +3175,11 @@ async function loggedPost(api, cwd, endpoint, payload, options = {}) {
2485
3175
  let responseData = null;
2486
3176
  let error = null;
2487
3177
  let responseFile = null;
3178
+ const actor = resolveLocalActor();
3179
+ const unsafePayloadLog = unsafePayloadLoggingEnabled();
3180
+ if (unsafePayloadLog && !options.skipSessionLog) {
3181
+ console.error('WARNING: REMITS_CLI_UNSAFE_LOG_PAYLOADS=1 is enabled; session logs may contain request payload values.');
3182
+ }
2488
3183
 
2489
3184
  try {
2490
3185
  responseData = await api.post(endpoint, payload).then((r) => r.data);
@@ -2502,15 +3197,19 @@ async function loggedPost(api, cwd, endpoint, payload, options = {}) {
2502
3197
  const status = error ? 'error' : 'success';
2503
3198
  const responseForLog = responseFile
2504
3199
  ? { responseFile, note: 'Tool response stored externally' }
2505
- : sanitizeForLog(responseData);
3200
+ : summarizeResponseForLog(responseData);
2506
3201
  appendSessionLog(cwd, {
2507
3202
  ts: new Date().toISOString(),
2508
3203
  requestId,
2509
3204
  endpoint,
2510
3205
  method: 'POST',
3206
+ actor: actor.id,
3207
+ actorSource: actor.source,
2511
3208
  status,
2512
3209
  durationMs: Date.now() - started,
2513
- request: sanitizeForLog(payload),
3210
+ requestSummary: summarizeRequestForLog(payload),
3211
+ unsafePayloadLogging: unsafePayloadLog,
3212
+ request: requestPayloadForSessionLog(payload),
2514
3213
  response: responseForLog,
2515
3214
  error: error ? describeError(error) : null
2516
3215
  });
@@ -2523,6 +3222,7 @@ async function loggedGet(api, cwd, endpoint, params = {}) {
2523
3222
  const started = Date.now();
2524
3223
  let responseData = null;
2525
3224
  let error = null;
3225
+ const actor = resolveLocalActor();
2526
3226
  try {
2527
3227
  responseData = await api.get(endpoint, { params }).then((r) => r.data);
2528
3228
  return { data: responseData, requestId };
@@ -2536,10 +3236,13 @@ async function loggedGet(api, cwd, endpoint, params = {}) {
2536
3236
  requestId,
2537
3237
  endpoint,
2538
3238
  method: 'GET',
3239
+ actor: actor.id,
3240
+ actorSource: actor.source,
2539
3241
  status: error ? 'error' : 'success',
2540
3242
  durationMs: Date.now() - started,
2541
- request: sanitizeForLog(params),
2542
- response: sanitizeForLog(responseData),
3243
+ requestSummary: summarizeRequestForLog(params),
3244
+ request: requestPayloadForSessionLog(params),
3245
+ response: summarizeResponseForLog(responseData),
2543
3246
  error: error ? describeError(error) : null
2544
3247
  });
2545
3248
  }
@@ -3079,6 +3782,7 @@ async function pushComponentsCommand(flags) {
3079
3782
  const emptyWorksetPolicy = normalizeEmptyWorksetPolicy(flags);
3080
3783
 
3081
3784
  let components = collectComponents(cwd);
3785
+ const componentDiscoveryWarnings = Array.isArray(components.discoveryWarnings) ? components.discoveryWarnings : [];
3082
3786
  // Named the way the payload is named, BEFORE anything compares the two — see alignChangedSetWithComponents.
3083
3787
  const changedFromWorkingTree = alignChangedSetWithComponents(changedFromGit, components);
3084
3788
  // Changes git reported that a component payload cannot carry — a deleted component file has nothing to
@@ -3138,7 +3842,9 @@ async function pushComponentsCommand(flags) {
3138
3842
  : null,
3139
3843
  changedFromWorkingTree: [],
3140
3844
  changedFromWorkingTreeAvailable: true,
3141
- unrepresentableChanges: unstageable
3845
+ unrepresentableChanges: unstageable,
3846
+ componentDiscoveryWarnings,
3847
+ localState: localStateSummary(cwd, flags)
3142
3848
  };
3143
3849
  if (flagEnabled(flags.json)) {
3144
3850
  console.log(JSON.stringify(emptyResponse, null, 2));
@@ -3150,6 +3856,9 @@ async function pushComponentsCommand(flags) {
3150
3856
  console.log(' or re-run with `--empty-workset clear` if an empty lane is what you meant.');
3151
3857
  }
3152
3858
  printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
3859
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace, dataMode });
3860
+ printLocalStateWarnings(cwd, flags);
3861
+ printComponentDiscoveryWarnings(componentDiscoveryWarnings);
3153
3862
  printUnrepresentableChanges(unstageable);
3154
3863
  }
3155
3864
  return emptyResponse;
@@ -3238,6 +3947,8 @@ async function pushComponentsCommand(flags) {
3238
3947
  response.changedFromWorkingTreeAvailable = changedFromWorkingTree !== null;
3239
3948
  response.requestedStageMode = stageMode;
3240
3949
  response.unrepresentableChanges = unstageable;
3950
+ response.componentDiscoveryWarnings = componentDiscoveryWarnings;
3951
+ response.localState = localStateSummary(cwd, flags);
3241
3952
  if (response.success === false) {
3242
3953
  // A 200 that says `success:false`. The 422 path throws out of stageOrRefuse above and never lands
3243
3954
  // here, so this stays as the belt-and-braces case — print whatever detail came with it first.
@@ -3271,6 +3982,9 @@ async function pushComponentsCommand(flags) {
3271
3982
 
3272
3983
  printSessionResolutionWarning(sessionContext);
3273
3984
  printResolvedBaseUrl(baseUrl);
3985
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace, dataMode: response.dataMode || dataMode });
3986
+ printLocalStateWarnings(cwd, flags);
3987
+ printComponentDiscoveryWarnings(componentDiscoveryWarnings);
3274
3988
  printStagingLane(response.branchName || branchName, response.workspace || workspace, workspaceSource(cwd, flags));
3275
3989
  console.log('Data mode:', response.dataMode || dataMode);
3276
3990
  printComponentPolicy(response);
@@ -3473,6 +4187,15 @@ function printComponentPolicy(response) {
3473
4187
  (policy.overrides || []).forEach((id) => console.log('Policy override recorded for rule: ' + id));
3474
4188
  }
3475
4189
 
4190
+ function printComponentDiscoveryWarnings(warnings) {
4191
+ const list = Array.isArray(warnings) ? warnings : [];
4192
+ if (!list.length) return;
4193
+ console.log('Component discovery warnings:');
4194
+ list.forEach((warning) => {
4195
+ console.log(' - ' + (warning.message || JSON.stringify(warning)));
4196
+ });
4197
+ }
4198
+
3476
4199
  /**
3477
4200
  * What the platform compiled, and what it could not.
3478
4201
  *
@@ -4042,6 +4765,8 @@ async function workspaceCommand(flags) {
4042
4765
  const branchName = flags.branch || currentBranch(cwd);
4043
4766
  const workspace = resolveWorkspace(cwd, flags);
4044
4767
  printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
4768
+ printLocalActor(cwd, flags);
4769
+ printLocalStateWarnings(cwd, flags);
4045
4770
  console.log('');
4046
4771
  console.log('Precedence: --workspace > REMITS_WORKSPACE > .remits-cli/workspace > shared default lane');
4047
4772
  console.log('');
@@ -4051,6 +4776,74 @@ async function workspaceCommand(flags) {
4051
4776
  console.log(' remits-cli components status see every lane staged on this branch');
4052
4777
  }
4053
4778
 
4779
+ function printFileRows(label, rows, max = 5) {
4780
+ console.log(label + ':', rows.length);
4781
+ rows.slice(0, max).forEach((row) => {
4782
+ console.log(' - ' + row.path + ' (' + row.size + ' bytes' + (row.updatedAt ? ', updated ' + row.updatedAt : '') + ')');
4783
+ });
4784
+ if (rows.length > max) {
4785
+ console.log(' ...' + (rows.length - max) + ' more');
4786
+ }
4787
+ }
4788
+
4789
+ async function doctorCommand(flags, subcommand) {
4790
+ const sub = String(subcommand || (flags._ && flags._[1]) || 'local-state').toLowerCase();
4791
+ if (sub !== 'local-state' && sub !== 'state') {
4792
+ throw new Error('Unknown doctor subcommand "' + sub + '". Expected local-state.');
4793
+ }
4794
+ const cwd = process.cwd();
4795
+ if (flagEnabled(flags['prune-actors'])) {
4796
+ const pruned = pruneStaleActorDirectories(cwd);
4797
+ console.log('Pruned ' + pruned.removed.length + ' idle actor dir(s)' +
4798
+ (pruned.kept ? ', kept ' + pruned.kept + ' (current or active in the last 7 days)' : '') + '.');
4799
+ pruned.removed.slice(0, 20).forEach((name) => console.log(' - ' + name));
4800
+ if (pruned.removed.length > 20) console.log(' ...' + (pruned.removed.length - 20) + ' more');
4801
+ if (pruned.failed.length) console.log(' Could not remove: ' + pruned.failed.join(', '));
4802
+ }
4803
+ const { report, warnings } = localStateWarnings(cwd, flags);
4804
+ if (flagEnabled(flags.json)) {
4805
+ console.log(JSON.stringify(report, null, 2));
4806
+ return report;
4807
+ }
4808
+ console.log('Local state doctor');
4809
+ console.log('Working directory:', cwd);
4810
+ console.log('Local actor:', report.actor.id + ' (from ' + report.actor.source + ')');
4811
+ console.log('Active actor state:', report.activeActorStateDir);
4812
+ console.log('Workspace:', report.workspace || 'shared default lane', ' [' + report.workspaceSource + ']');
4813
+ console.log('Workspace file:', report.workspaceFile);
4814
+ if (warnings.length) {
4815
+ console.log('');
4816
+ console.log('Warnings:');
4817
+ warnings.forEach((warning) => console.log(' - ' + warning));
4818
+ }
4819
+ console.log('');
4820
+ console.log('Actor directories:', report.actors.length);
4821
+ const listedActors = report.actors
4822
+ .slice()
4823
+ .sort((a, b) => (a.current ? -1 : b.current ? 1 : Date.parse(b.updatedAt || 0) - Date.parse(a.updatedAt || 0)));
4824
+ listedActors.slice(0, 15).forEach((actor) => {
4825
+ console.log(' ' + (actor.current ? '* ' : ' ') + actor.actor + ' ' + actor.path + (actor.updatedAt ? ' updated ' + actor.updatedAt : ''));
4826
+ });
4827
+ if (listedActors.length > 15) console.log(' ...' + (listedActors.length - 15) + ' more (newest first; --prune-actors clears the idle ones)');
4828
+ console.log(' (* = active actor)');
4829
+ console.log('');
4830
+ console.log('.remits-cli tracked by git:', report.trackedFiles.length ? 'YES' : 'no');
4831
+ report.trackedFiles.slice(0, 20).forEach((file) => console.log(' - ' + file));
4832
+ if (report.trackedFiles.length > 20) console.log(' ...' + (report.trackedFiles.length - 20) + ' more');
4833
+ console.log('');
4834
+ printFileRows('Current actor session logs', report.current.sessionLogs);
4835
+ printFileRows('Current actor tool responses', report.current.toolResponses);
4836
+ printFileRows('Legacy session logs', report.legacy.sessionLogs);
4837
+ printFileRows('Legacy tool responses', report.legacy.toolResponses);
4838
+ printFileRows('Largest local-state files', report.largestFiles, 10);
4839
+ const suspicious = report.current.likelyRawPayloadLogs.concat(report.legacy.likelyRawPayloadLogs);
4840
+ if (suspicious.length) {
4841
+ console.log('');
4842
+ printFileRows('Logs with raw/unsafe payload-shaped fields', suspicious, 10);
4843
+ }
4844
+ return report;
4845
+ }
4846
+
4054
4847
  async function statusComponentsCommand(flags) {
4055
4848
  const cwd = process.cwd();
4056
4849
  ensureLocalState(cwd);
@@ -4091,6 +4884,7 @@ async function statusComponentsCommand(flags) {
4091
4884
  isAncestor: (a, b) => gitIsAncestor(cwd, a, b)
4092
4885
  });
4093
4886
  response.repositoryCheck = repositoryCheck(cwd, response.branchContext);
4887
+ response.localState = localStateSummary(cwd, flags);
4094
4888
 
4095
4889
  await appendVerificationPacket(api, cwd, session, accountId, flags, {
4096
4890
  type: 'component_status',
@@ -4110,6 +4904,8 @@ async function statusComponentsCommand(flags) {
4110
4904
 
4111
4905
  printSessionResolutionWarning(sessionContext);
4112
4906
  printResolvedBaseUrl(baseUrl);
4907
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace, dataMode: response.dataMode || dataMode });
4908
+ printLocalStateWarnings(cwd, flags);
4113
4909
  console.log('Data mode:', response.dataMode || dataMode);
4114
4910
  console.log('Mode:', response.mode || 'status');
4115
4911
  printStatusSummary(response, flags);
@@ -4542,7 +5338,7 @@ async function syncComponentsCommand(rawFlags) {
4542
5338
  type: response.sync && response.sync.dryRun ? 'sync_dry_run' : 'sync_mutation',
4543
5339
  success: response.success !== false,
4544
5340
  claim: response.sync && response.sync.dryRun ? 'Component sync dry-run observed' : 'Component sync completed',
4545
- world: buildCommandWorld(response, { accountId, dataMode, branchName, workspace, host: normalizeBaseUrl(baseUrl), sourceLayer: response.sync && response.sync.mode === 'variant' ? 'variant' : 'trunk' }),
5341
+ world: syncPacketWorld(response, buildCommandWorld(response, { accountId, dataMode, branchName, workspace, host: normalizeBaseUrl(baseUrl), sourceLayer: response.sync && response.sync.mode === 'variant' ? 'variant' : 'trunk' }), branchName),
4546
5342
  revision: Object.assign(collectVerificationSource(cwd, flags), {
4547
5343
  platformSyncedSha: response.sync && (response.sync.postSyncSha || response.sync.branchHeadSha)
4548
5344
  }),
@@ -4551,7 +5347,7 @@ async function syncComponentsCommand(rawFlags) {
4551
5347
  { category: response.sync && response.sync.dryRun ? 'sync_dry_run' : 'sync_mutation', summary }
4552
5348
  ],
4553
5349
  rawRefs: { command: 'components sync' },
4554
- sync: response.sync,
5350
+ sync: syncEvidencePayload(response.sync),
4555
5351
  summary
4556
5352
  }, { quiet: flagEnabled(flags.json) });
4557
5353
 
@@ -4582,11 +5378,31 @@ async function syncComponentsCommand(rawFlags) {
4582
5378
  return response;
4583
5379
  }
4584
5380
  console.log('Components sync:', JSON.stringify(response, null, 2));
5381
+ printVariantSyncWrites(summary);
4585
5382
  printPromotionSignals(response);
4586
5383
  failOnSyncGate(gate);
4587
5384
  return response;
4588
5385
  }
4589
5386
 
5387
+ // The one line a person needs from a variant sync of a long-lived branch: what THIS sync changes, apart from
5388
+ // the overlays it merely re-confirms (the totals alone read the same on every sync).
5389
+ function printVariantSyncWrites(summary) {
5390
+ const writes = summary && summary.writes;
5391
+ if (!writes || summary.mode !== 'variant') return;
5392
+ console.log('');
5393
+ if (!writes.known) {
5394
+ console.log('Overlay changes: unknown (this platform does not report which overlays were already stored).');
5395
+ return;
5396
+ }
5397
+ const verb = summary.dryRun ? 'would change' : 'changed';
5398
+ console.log('Overlay changes: ' + writes.changed + ' ' + verb + ', ' + writes.alreadyCurrent + ' already current, ' +
5399
+ writes.removed + ' removed, ' + writes.pruned + ' pruned (converged back to trunk)');
5400
+ (writes.changedComponents || []).forEach((entry) => {
5401
+ console.log(' ' + String(entry.type || 'component').toLowerCase() + ':' + (entry.id != null ? entry.id : entry.name) +
5402
+ (entry.name && entry.id != null ? ' ' + entry.name : ''));
5403
+ });
5404
+ }
5405
+
4590
5406
  // A sync is a step in a longer loop, and the steps after it are the ones that get skipped — so print what
4591
5407
  // the server just observed about this branch's relationship to trunk, not only what it wrote.
4592
5408
  function printPromotionSignals(response) {
@@ -4755,7 +5571,9 @@ function syncPlanEntries(response) {
4755
5571
  type: String(entry.type || entry.kind || 'component').toLowerCase(),
4756
5572
  id: entry.id == null ? null : String(entry.id),
4757
5573
  name: entry.name || null,
4758
- path: entry.path || null
5574
+ path: entry.path || null,
5575
+ // true when the overlay already stored for this component matches the branch: re-confirmed, not changed.
5576
+ storedCurrent: entry.storedCurrent === true
4759
5577
  });
4760
5578
  });
4761
5579
  });
@@ -4801,6 +5619,8 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
4801
5619
  const expectedRemoved = parseExpectedRemoved(flags);
4802
5620
  const violations = [];
4803
5621
  const checks = {};
5622
+ // Overlays the --changed-only gate did not count because the platform already stores them with this content.
5623
+ let alreadyCurrentCount = null;
4804
5624
 
4805
5625
  if (flagEnabled(flags['fail-on-errors']) || flagEnabled(flags.failOnErrors)) {
4806
5626
  checks.failOnErrors = errors.length === 0;
@@ -4840,13 +5660,20 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
4840
5660
  const allowedIds = new Set(changedSet.filter((c) => c.id != null).map((c) => c.type + ':' + c.id));
4841
5661
  const allowedNames = new Set(changedSet.filter((c) => c.name).map((c) => c.type + ':' + String(c.name).toLowerCase()));
4842
5662
  const allowedPaths = new Set([].concat(...changedSet.map((c) => (Array.isArray(c.paths) ? c.paths : []))).map(String));
5663
+ // An overlay the platform ALREADY stores with this exact content is not something this sync changes. A
5664
+ // long-lived branch (a permanent customer release train) re-reports every one of its overlays on every sync,
5665
+ // so counting those made the gate refuse ordinary work with "plan touches 80 components" and blame a stale
5666
+ // branch that `components promotion` reported as ready. Removals are never exempt.
5667
+ const alreadyCurrent = entries.filter((entry) => entry.storedCurrent && entry.bucket !== 'removed');
4843
5668
  const unexpected = entries.filter((entry) => {
5669
+ if (entry.storedCurrent && entry.bucket !== 'removed') return false;
4844
5670
  if (entry.path && allowedPaths.has(String(entry.path))) return false;
4845
5671
  if (entry.id != null && allowedIds.has(entry.type + ':' + entry.id)) return false;
4846
5672
  if (entry.name && allowedNames.has(entry.type + ':' + String(entry.name).toLowerCase())) return false;
4847
5673
  return true;
4848
5674
  });
4849
5675
  checks.changedOnly = unexpected.length === 0;
5676
+ alreadyCurrentCount = alreadyCurrent.length;
4850
5677
  if (unexpected.length) {
4851
5678
  // Name the most likely cause instead of only the symptom. An empty changed set with a non-empty
4852
5679
  // plan is the ordinary post-commit state, not evidence that the plan is dangerous.
@@ -4858,11 +5685,10 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
4858
5685
  // physically carries old copies of files nobody on it touched, and a variant sync turns each of
4859
5686
  // those into an unrelated override. Naming the cause is the difference between a gate that
4860
5687
  // stops the damage and a gate that also tells you how to clear it.
4861
- : (changedSet.length && unexpected.length > changedSet.length)
4862
- ? '\n Most likely cause: this branch is BEHIND trunk and still carries old copies of files ' +
4863
- 'it never changed. A variant sync turns each of those into an unrelated override.' +
4864
- '\n Next step: merge trunk into this branch, push, then re-run the same command:' +
4865
- '\n git merge <trunk> && git push origin <branch>'
5688
+ // With --changed-since the empty-set hint above does not apply, so a ref that shows no committed change
5689
+ // still needs the cause named — that is exactly the behind-trunk branch whose plan is all stale copies.
5690
+ : ((changedSet.length || since) && unexpected.length > changedSet.length)
5691
+ ? staleBranchGateHint(response)
4866
5692
  : '';
4867
5693
  violations.push('--changed-only: plan touches ' + unexpected.length + ' component(s) this checkout did not change: ' +
4868
5694
  unexpected.slice(0, 20).map(syncEntryToken).join(', ') + (unexpected.length > 20 ? ', ...' : '') + hint);
@@ -4870,7 +5696,28 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
4870
5696
  }
4871
5697
  }
4872
5698
 
4873
- return { violations, checks };
5699
+ return { violations, checks, alreadyCurrentCount };
5700
+ }
5701
+
5702
+ // Name the stale-branch cause only when the platform's own git comparison says the branch IS behind trunk. The
5703
+ // hint used to fire on the count alone, and told agents on an up-to-date permanent branch to merge trunk in.
5704
+ function staleBranchGateHint(response) {
5705
+ const sync = (response && response.sync) || {};
5706
+ const comparison = sync.branchComparison || (sync.promotion && sync.promotion.git) || null;
5707
+ const behindBy = comparison && Number.isFinite(Number(comparison.behindBy)) ? Number(comparison.behindBy) : null;
5708
+ if (behindBy !== null && behindBy > 0) {
5709
+ return '\n Most likely cause: this branch is BEHIND trunk by ' + behindBy + ' commit(s) and still carries old copies ' +
5710
+ 'of files it never changed. A variant sync turns each of those into an unrelated override.' +
5711
+ '\n Next step: merge trunk into this branch, push, then re-run the same command:' +
5712
+ '\n git merge <trunk> && git push origin <branch>';
5713
+ }
5714
+ if (behindBy === 0) {
5715
+ return '\n The branch is current with trunk, so these are not stale copies: they are overlays whose stored content ' +
5716
+ 'differs from this branch but that this checkout\'s changed set does not name. Widen --changed-since to the ' +
5717
+ 'commit the platform last synced (components promotion shows it), or review the plan with --dry-run.';
5718
+ }
5719
+ return '\n The platform could not compare this branch with trunk. Run `remits-cli components promotion` to see ' +
5720
+ 'whether it is behind before merging anything.';
4874
5721
  }
4875
5722
 
4876
5723
  function failOnSyncGate(gate) {
@@ -5163,6 +6010,36 @@ function printTrunkSyncWarning(trunkBranch, options) {
5163
6010
  emit(' For a gated plan, work on a variant branch instead.');
5164
6011
  }
5165
6012
 
6013
+ function variantSyncWrites(overridden, added, removed, unchanged) {
6014
+ const overlays = overridden.concat(added);
6015
+ const known = overlays.every((entry) => entry && typeof entry.storedCurrent === 'boolean');
6016
+ const pruned = unchanged.filter((entry) => entry && entry.pruned === true).length;
6017
+ if (!known) {
6018
+ return { changed: null, alreadyCurrent: null, removed: removed.length, pruned, known: false };
6019
+ }
6020
+ const changed = overlays.filter((entry) => !entry.storedCurrent);
6021
+ return {
6022
+ changed: changed.length,
6023
+ changedComponents: changed.slice(0, 50).map((entry) => ({ type: entry.type, name: entry.name, id: entry.id })),
6024
+ alreadyCurrent: overlays.length - changed.length,
6025
+ removed: removed.length,
6026
+ pruned,
6027
+ known: true
6028
+ };
6029
+ }
6030
+
6031
+ // `|` separates selectors and a repeated --names needs no separator. A COMMA is never a separator any more: it
6032
+ // is sent as part of the selector, and the platform matches a comma-bearing selector either as one whole case name
6033
+ // ("… the complete statement, offer, and surcharge matrix") or, for older invocations, as its comma-separated
6034
+ // parts (Test.selectorMatchesCase). Splitting in the CLI took the real name apart before the platform could see it.
6035
+ function parseTestCaseSelectors(rawNames) {
6036
+ return (Array.isArray(rawNames) ? rawNames : [rawNames])
6037
+ .filter((value) => value !== undefined && value !== null)
6038
+ .flatMap((value) => String(value).split('|'))
6039
+ .map((value) => value.trim())
6040
+ .filter(Boolean);
6041
+ }
6042
+
5166
6043
  function buildSyncSummary(response) {
5167
6044
  const sync = (response && response.sync) || {};
5168
6045
  const results = sync.syncResults || {};
@@ -5292,6 +6169,10 @@ function buildSyncSummary(response) {
5292
6169
  gateViolations: (response && response.gateViolations) || null,
5293
6170
  overridden: overridden.length,
5294
6171
  added: added.length,
6172
+ // What THIS sync changes, as opposed to the whole overlay set it re-reports. `overridden`/`added` are totals,
6173
+ // so five consecutive syncs of a long-lived branch all printed `overridden: 48, added: 32` and nobody could see
6174
+ // which overlays their own sync moved. Null counts mean the platform predates the storedCurrent flag.
6175
+ writes: variantSyncWrites(overridden, added, removed, unchanged),
5295
6176
  removed: removed.map((entry) => ({
5296
6177
  type: entry.type,
5297
6178
  kind: entry.kind,
@@ -5678,6 +6559,17 @@ function short(sha) {
5678
6559
  return sha ? String(sha).slice(0, 8) : 'unknown';
5679
6560
  }
5680
6561
 
6562
+ // A component signature is structured, not a bare hash: 'version:12:ab34cd', 'content:<sha>', 'cli:<hash>'.
6563
+ // Truncating the whole thing to 8 characters rendered the commonest case as the literal string 'version:' —
6564
+ // the prefix exactly fills the budget and the part that identifies the code is what got cut.
6565
+ function shortSignature(signature) {
6566
+ if (!signature) return 'unknown';
6567
+ const parts = String(signature).split(':');
6568
+ if (parts.length < 2) return short(signature);
6569
+ const last = parts.pop();
6570
+ return parts.concat(short(last)).join(':');
6571
+ }
6572
+
5681
6573
  async function commitComponentsCommand(flags) {
5682
6574
  const cwd = process.cwd();
5683
6575
  ensureLocalState(cwd);
@@ -6229,7 +7121,12 @@ function printTestRunPivots(status = {}) {
6229
7121
  }
6230
7122
  tests.filter((test) => test && test.passed === false).forEach((test) => {
6231
7123
  console.log('Pivots for failed case:', test.name || '(unnamed)');
7124
+ if (test.outcome) console.log(' Outcome:', test.outcome + (test.outcomeReason && test.outcomeReason !== test.error ? ' - ' + String(test.outcomeReason).slice(0, 300) : ''));
6232
7125
  if (test.duration != null) console.log(' Duration:', formatDurationMs(test.duration));
7126
+ if (test.ai && (test.ai.calls || test.ai.liveCalls)) {
7127
+ console.log(' AI:', (test.ai.liveCalls || 0) + ' live ' + formatUsd(test.ai.cost) + ', ' + (test.ai.mockedCalls || 0) + ' mocked' +
7128
+ ((test.ai.errors || []).length ? ', errors: ' + test.ai.errors.map((e) => (e.classification || 'error') + ' ' + (e.model || '')).join('; ') : ''));
7129
+ }
6233
7130
  if (test.threadGroupingId || test.threadGroupId || test.traceId) {
6234
7131
  console.log(' Trace:', test.traceId || test.threadGroupingId || test.threadGroupId);
6235
7132
  }
@@ -6245,7 +7142,8 @@ function printTestRunPivots(status = {}) {
6245
7142
  (entry.signature ? ' ' + short(entry.signature) : '')).join(', '));
6246
7143
  }
6247
7144
  if (Array.isArray(test.liveHttpCalls) && test.liveHttpCalls.length) {
6248
- console.log(' Live HTTP calls:', test.liveHttpCalls.length);
7145
+ const intentional = test.liveHttpCalls.filter((call) => call && call.intentional === true).length;
7146
+ console.log(' Live HTTP calls:', test.liveHttpCalls.length + (intentional ? ' (' + intentional + ' intentional, inside withAiBudget)' : ''));
6249
7147
  }
6250
7148
  });
6251
7149
  }
@@ -6257,6 +7155,249 @@ function formatDurationMs(value) {
6257
7155
  return (ms / 1000).toFixed(ms < 10000 ? 1 : 0) + 's';
6258
7156
  }
6259
7157
 
7158
+ // A local corpus manifest -> case entries, reading expected/source JSON files and artifact paths RELATIVE to the
7159
+ // manifest. Artifacts are described (path, size) and only base64-encoded at send time, per batch.
7160
+ function corpusManifestEntries(manifest, manifestDir) {
7161
+ const root = manifest && typeof manifest === 'object' ? manifest : {};
7162
+ const cases = Array.isArray(root.cases) ? root.cases : [];
7163
+ const readJson = (file) => JSON.parse(fs.readFileSync(path.resolve(manifestDir, file), 'utf8'));
7164
+ return cases.map((raw, index) => {
7165
+ const entry = raw && typeof raw === 'object' ? raw : {};
7166
+ if (!entry.caseKey) throw new Error('cases[' + index + '] has no caseKey');
7167
+ const out = { caseKey: String(entry.caseKey) };
7168
+ ['split', 'tags', 'expected', 'expectedStatus', 'source', 'notes', 'retired'].forEach((key) => {
7169
+ if (entry[key] !== undefined) out[key] = entry[key];
7170
+ });
7171
+ if (typeof entry.expectedFile === 'string') out.expected = readJson(entry.expectedFile);
7172
+ if (typeof entry.sourceFile === 'string') out.source = readJson(entry.sourceFile);
7173
+ out.artifacts = (Array.isArray(entry.artifacts) ? entry.artifacts : []).map((artifact, artifactIndex) => {
7174
+ const spec = typeof artifact === 'string' ? { path: artifact } : (artifact || {});
7175
+ if (!spec.path) throw new Error('cases[' + index + '].artifacts[' + artifactIndex + '] has no path');
7176
+ const resolved = path.resolve(manifestDir, spec.path);
7177
+ if (!fs.existsSync(resolved)) throw new Error('cases[' + index + '] artifact not found: ' + resolved);
7178
+ return {
7179
+ name: spec.name || (artifactIndex === 0 ? 'source' : path.basename(resolved).replace(/[^A-Za-z0-9._-]+/g, '_')),
7180
+ path: resolved,
7181
+ fileName: spec.fileName || path.basename(resolved),
7182
+ contentType: spec.contentType || undefined,
7183
+ bytes: fs.statSync(resolved).size
7184
+ };
7185
+ });
7186
+ return out;
7187
+ });
7188
+ }
7189
+
7190
+ // Split corpus case entries into requests under the server's per-request limits (artifact bytes and count).
7191
+ function batchCorpusEntries(entries, maxBytes = 40 * 1024 * 1024, maxCount = 100) {
7192
+ const batches = [];
7193
+ let current = [];
7194
+ let currentBytes = 0;
7195
+ (entries || []).forEach((entry) => {
7196
+ const bytes = (entry.artifacts || []).reduce((sum, a) => sum + Number(a.bytes || 0), 0);
7197
+ if (current.length && (current.length >= maxCount || currentBytes + bytes > maxBytes)) {
7198
+ batches.push(current);
7199
+ current = [];
7200
+ currentBytes = 0;
7201
+ }
7202
+ current.push(entry);
7203
+ currentBytes += bytes;
7204
+ });
7205
+ if (current.length) batches.push(current);
7206
+ return batches;
7207
+ }
7208
+
7209
+ // The corpus_import verification packet: case keys, statuses, changed KEYS, artifact ids/hashes/sizes. Never an
7210
+ // expected answer, a provenance value or a byte of artifact content.
7211
+ function corpusImportPacketBody(responses) {
7212
+ const cases = [];
7213
+ let world = null;
7214
+ let corpus = null;
7215
+ let ownerAccountId = null;
7216
+ (responses || []).forEach((response) => {
7217
+ if (!response) return;
7218
+ if (response.world && !world) world = response.world;
7219
+ corpus = corpus || response.corpus;
7220
+ ownerAccountId = ownerAccountId || response.ownerAccountId;
7221
+ (Array.isArray(response.cases) ? response.cases : []).forEach((c) => {
7222
+ cases.push({
7223
+ caseKey: c.caseKey,
7224
+ status: c.status,
7225
+ changedKeys: c.changedKeys,
7226
+ artifacts: (c.artifacts || []).map((a) => ({ name: a.name, objectId: a.objectId, sha256: a.sha256, bytes: a.bytes, created: a.created })),
7227
+ message: c.status === 'failed' ? c.message : undefined
7228
+ });
7229
+ });
7230
+ });
7231
+ const counts = cases.reduce((acc, c) => { acc[c.status] = (acc[c.status] || 0) + 1; return acc; }, {});
7232
+ return { world, corpus, ownerAccountId, counts, cases, success: cases.length > 0 && !cases.some((c) => c.status === 'failed') };
7233
+ }
7234
+
7235
+ function parseCorpusFilterValues(flags, names) {
7236
+ const values = [];
7237
+ (names || []).forEach((name) => {
7238
+ const raw = flags[name];
7239
+ if (raw === undefined || raw === null || raw === true) return;
7240
+ (Array.isArray(raw) ? raw : [raw]).forEach((value) => {
7241
+ String(value).split(',').forEach((part) => {
7242
+ const trimmed = part.trim();
7243
+ if (trimmed) values.push(trimmed);
7244
+ });
7245
+ });
7246
+ });
7247
+ return Array.from(new Set(values));
7248
+ }
7249
+
7250
+ function testRunDependencies(status = {}) {
7251
+ const tests = status.result && Array.isArray(status.result.tests) ? status.result.tests : [];
7252
+ const liveHttpCalls = [];
7253
+ tests.forEach((test) => (Array.isArray(test.liveHttpCalls) ? test.liveHttpCalls : []).forEach((call) => {
7254
+ liveHttpCalls.push(Object.assign({ caseName: test.name }, call));
7255
+ }));
7256
+ const ai = status.result && status.result.summary && status.result.summary.ai;
7257
+ return {
7258
+ liveHttpCalls: liveHttpCalls.slice(0, 50),
7259
+ unintendedLiveHttpCalls: liveHttpCalls.filter((call) => call.intentional !== true).length,
7260
+ liveAi: ai ? {
7261
+ liveCalls: ai.liveCalls || 0,
7262
+ mockedCalls: ai.mockedCalls || 0,
7263
+ cost: ai.cost,
7264
+ intentional: (ai.intentionalLiveCalls || 0) > 0 && (ai.intentionalLiveCalls || 0) >= (ai.liveCalls || 0),
7265
+ intentionalLiveCalls: ai.intentionalLiveCalls || 0,
7266
+ budgets: ai.budgets || []
7267
+ } : null
7268
+ };
7269
+ }
7270
+
7271
+ function formatUsd(value) {
7272
+ const n = Number(value);
7273
+ if (!Number.isFinite(n)) return 'unknown';
7274
+ // Real spend must never render as $0.00. Per-call costs of a few microdollars are normal for small models,
7275
+ // and a live run printing "$0.0000" reads as "this cost nothing", which is how a live run gets mistaken for
7276
+ // a replay — the exact confusion the AI-mode labelling exists to prevent.
7277
+ if (n !== 0 && Math.abs(n) < 0.0001) return (n < 0 ? '-' : '') + '<$0.0001';
7278
+ return '$' + n.toFixed(n !== 0 && Math.abs(n) < 0.01 ? 4 : 2);
7279
+ }
7280
+
7281
+ // The world a run executes in, as the lines printed BEFORE the result: host, who it runs as, the data lane,
7282
+ // the component world, the staging lane with its content hash, and the platform sync against the local HEAD.
7283
+ // One block, stated once, so an agent never has to assemble "which world was that?" from five other outputs —
7284
+ // the localhost-vs-prod mismatch that sent account 21 in circles was two of these lines disagreeing.
7285
+ function runWorldLines(world, context = {}) {
7286
+ if (!world || typeof world !== 'object') return [];
7287
+ const lines = ['World:'];
7288
+ if (context.host) lines.push(' host: ' + context.host);
7289
+ const accounts = ['account ' + (world.executionAccountId || context.accountId || 'unknown')];
7290
+ if (world.checkoutAccountId && world.checkoutAccountId !== world.executionAccountId) accounts.push('checkout ' + world.checkoutAccountId);
7291
+ if (world.componentOwnerAccountId && world.componentOwnerAccountId !== world.executionAccountId) accounts.push('component owner ' + world.componentOwnerAccountId);
7292
+ lines.push(' runs as: ' + accounts.join(', '));
7293
+ lines.push(' data lane: ' + (world.dataMode || context.dataMode || 'unknown'));
7294
+ const branchSource = world.variantBranchSource ? ' [' + world.variantBranchSource + ']' : '';
7295
+ lines.push(' component world: ' + (world.resolves || (world.componentBranch ? "trunk + the '" + world.componentBranch + "' variant overlays" : 'trunk')) + branchSource);
7296
+ const laneParts = [world.stagingLane || (world.branchName ? world.branchName + (world.workspace ? ' [ws:' + world.workspace + ']' : '') : 'none')];
7297
+ if (world.sharedLane) laneParts.push('(shared lane)');
7298
+ if (world.laneContentHash) laneParts.push('content ' + String(world.laneContentHash).slice(0, 12));
7299
+ if (world.stagedCount !== undefined && world.stagedCount !== null) laneParts.push(world.stagedCount + ' staged');
7300
+ lines.push(' staging lane: ' + laneParts.join(' '));
7301
+ if (world.laneId) lines.push(' lane id: ' + world.laneId);
7302
+ const localHead = context.gitHead ? String(context.gitHead).slice(0, 8) : null;
7303
+ const synced = world.platformSyncSha ? String(world.platformSyncSha).slice(0, 8) : null;
7304
+ if (synced || localHead) {
7305
+ let syncLine = ' platform sync: ' + (synced || 'unknown') + (localHead ? ' local HEAD ' + localHead : '');
7306
+ if (synced && localHead && !String(context.gitHead).startsWith(String(world.platformSyncSha).slice(0, 8))) {
7307
+ syncLine += ' (differ: committed components run as last synced; only STAGED components reflect this checkout)';
7308
+ }
7309
+ lines.push(syncLine);
7310
+ }
7311
+ if (world.testComponentSource) {
7312
+ lines.push(' test component: ' + world.testComponentSource + (world.testComponentSignature ? ' ' + shortSignature(world.testComponentSignature) : '') +
7313
+ (world.testComponentBranch ? " variant '" + world.testComponentBranch + "'" : ''));
7314
+ }
7315
+ return lines;
7316
+ }
7317
+
7318
+ // The corpus-style roll-up printed after a run: outcome counts, case duration percentiles, AI usage split
7319
+ // live/mocked, and the durable record. Built only from the result the platform returned.
7320
+ function testRunSummaryLines(status = {}) {
7321
+ const result = status.result || {};
7322
+ const summary = result.summary || {};
7323
+ const lines = [];
7324
+ const outcomes = summary.outcomes && typeof summary.outcomes === 'object' ? summary.outcomes : null;
7325
+ if (outcomes && Object.keys(outcomes).length) {
7326
+ lines.push('Outcomes: ' + Object.keys(outcomes).map((key) => key + ' ' + outcomes[key]).join(', '));
7327
+ }
7328
+ if (summary.durationMs) {
7329
+ lines.push('Case duration: median ' + formatDurationMs(summary.durationMs.median) + ', p95 ' +
7330
+ formatDurationMs(summary.durationMs.p95) + ', max ' + formatDurationMs(summary.durationMs.max));
7331
+ }
7332
+ const ai = summary.ai;
7333
+ if (ai && (ai.calls || ai.liveCalls || ai.mockedCalls)) {
7334
+ let aiLine = 'AI: ' + (ai.liveCalls || 0) + ' live call(s) ' + formatUsd(ai.cost) + ', ' + (ai.mockedCalls || 0) + ' mocked';
7335
+ if (ai.uncostedLiveCalls) aiLine += ', ' + ai.uncostedLiveCalls + ' live call(s) unpriced (cost is a floor)';
7336
+ if (ai.intentionalLiveCalls) aiLine += ', ' + ai.intentionalLiveCalls + ' intentional (withAiBudget)';
7337
+ lines.push(aiLine);
7338
+ const models = Array.isArray(ai.models) ? ai.models : [];
7339
+ if (models.length) {
7340
+ lines.push('AI models: ' + models.slice(0, 6).map((m) => (m.model || 'unknown') + ' ' + (m.liveCalls || 0) + ' live/' + (m.mockedCalls || 0) + ' mocked').join('; '));
7341
+ }
7342
+ if (Array.isArray(ai.budgets) && ai.budgets.length) lines.push('AI budgets: ' + ai.budgets.join(', '));
7343
+ }
7344
+ const unintended = (Array.isArray(result.tests) ? result.tests : [])
7345
+ .reduce((sum, test) => sum + (Array.isArray(test.liveHttpCalls) ? test.liveHttpCalls.filter((c) => c && c.intentional !== true).length : 0), 0);
7346
+ if (unintended) {
7347
+ lines.push('Unmocked live HTTP calls: ' + unintended + ' (missing mocks, or live AI outside withAiBudget)');
7348
+ }
7349
+ if (result.durableRecord && result.durableRecord.stored) {
7350
+ lines.push('Durable record: kept after the live status expires — remits-cli test status --task-id ' + result.durableRecord.taskId);
7351
+ } else if (status.durable) {
7352
+ lines.push('Durable record: ' + (status.durableNote || 'read from the durable test run record'));
7353
+ }
7354
+ return lines;
7355
+ }
7356
+
7357
+ // Per-case comparison of two runs of the same suite: which cases changed outcome, and how cost and duration
7358
+ // moved. The answer to "did this revision do better than the last one" without reconstructing either run.
7359
+ function compareTestRunResults(baseResult = {}, headResult = {}) {
7360
+ const outcomeOf = (test) => test.outcome || (test.passed === true ? 'passed' : (test.passed === false ? 'failed' : 'unknown'));
7361
+ const byName = (result) => {
7362
+ const map = new Map();
7363
+ (Array.isArray(result.tests) ? result.tests : []).forEach((test) => { if (test && test.name) map.set(test.name, test); });
7364
+ return map;
7365
+ };
7366
+ const base = byName(baseResult);
7367
+ const head = byName(headResult);
7368
+ const names = Array.from(new Set([...base.keys(), ...head.keys()]));
7369
+ const cases = names.map((name) => {
7370
+ const b = base.get(name);
7371
+ const h = head.get(name);
7372
+ const entry = {
7373
+ name,
7374
+ base: b ? outcomeOf(b) : 'absent',
7375
+ head: h ? outcomeOf(h) : 'absent',
7376
+ baseCost: b && b.ai ? Number(b.ai.cost || 0) : null,
7377
+ headCost: h && h.ai ? Number(h.ai.cost || 0) : null,
7378
+ baseDurationMs: b ? b.duration : null,
7379
+ headDurationMs: h ? h.duration : null,
7380
+ headReason: h && h.outcome !== 'passed' ? (h.outcomeReason || h.error || null) : null
7381
+ };
7382
+ entry.changed = entry.base !== entry.head;
7383
+ entry.direction = !entry.changed ? 'same' : (entry.head === 'passed' ? 'improved' : (entry.base === 'passed' ? 'regressed' : 'changed'));
7384
+ return entry;
7385
+ });
7386
+ const count = (result, outcome) => (Array.isArray(result.tests) ? result.tests : []).filter((t) => outcomeOf(t) === outcome).length;
7387
+ const aiCost = (result) => (result.summary && result.summary.ai && Number(result.summary.ai.cost)) || 0;
7388
+ return {
7389
+ base: { taskId: baseResult.taskId, passed: count(baseResult, 'passed'), total: (baseResult.tests || []).length, cost: aiCost(baseResult) },
7390
+ head: { taskId: headResult.taskId, passed: count(headResult, 'passed'), total: (headResult.tests || []).length, cost: aiCost(headResult) },
7391
+ improved: cases.filter((c) => c.direction === 'improved').map((c) => c.name),
7392
+ regressed: cases.filter((c) => c.direction === 'regressed').map((c) => c.name),
7393
+ changed: cases.filter((c) => c.changed),
7394
+ cases
7395
+ };
7396
+ }
7397
+
7398
+
7399
+
7400
+
6260
7401
  async function waitForToolStatus(api, cwd, options) {
6261
7402
  const started = Date.now();
6262
7403
  let pollDelayMs = parsePositiveInt(options.pollIntervalMs, 1000);
@@ -6288,6 +7429,19 @@ async function waitForToolStatus(api, cwd, options) {
6288
7429
  }
6289
7430
  }
6290
7431
 
7432
+ // One compact progress line for a streamed TestSuite frame: a finished case, or the suite roll-up.
7433
+ function testSuiteProgressLine(payload = {}) {
7434
+ if (payload.total != null || Array.isArray(payload.tests)) {
7435
+ const total = payload.total != null ? payload.total : (payload.tests || []).length;
7436
+ const passed = payload.passed != null ? payload.passed : (payload.tests || []).filter((t) => t && t.passed).length;
7437
+ return '[ws] ' + (payload.testName || 'suite') + ': ' + passed + '/' + total + ' passed';
7438
+ }
7439
+ const mark = payload.passed === false ? 'FAIL' : 'pass';
7440
+ const detail = payload.outcome && payload.outcome !== 'passed' ? ' [' + payload.outcome + ']' : '';
7441
+ return '[ws] ' + mark + ' ' + (payload.name || 'case') + detail +
7442
+ (payload.duration != null ? ' ' + payload.duration + 'ms' : '');
7443
+ }
7444
+
6291
7445
  function watchWebsocket(baseUrl, topicId, taskId, options = {}) {
6292
7446
  const emit = options.stderr ? console.error : console.log;
6293
7447
  const client = new Client({
@@ -6308,7 +7462,11 @@ function watchWebsocket(baseUrl, topicId, taskId, options = {}) {
6308
7462
  return;
6309
7463
  }
6310
7464
  if (payload.tests || payload.name || payload.total != null) {
6311
- emit('[ws][TestSuite]', JSON.stringify(payload));
7465
+ // Progress, not the payload. This used to print the whole suite result — every case, every
7466
+ // stacktrace — as one enormous line immediately before the readable summary said the same thing,
7467
+ // which buried the run world, the outcomes and the pivots an agent actually reads. --verbose still
7468
+ // gets the raw frame.
7469
+ emit(options.verbose ? '[ws][TestSuite] ' + JSON.stringify(payload) : testSuiteProgressLine(payload));
6312
7470
  }
6313
7471
  }
6314
7472
  } catch (err) {
@@ -6345,26 +7503,12 @@ async function testCommand(flags) {
6345
7503
  throw new Error('Missing --test <id-or-name>');
6346
7504
  }
6347
7505
 
6348
- // Test case names are English prose, so COMMAS occur in them naturally. A comma-delimited selector
6349
- // therefore split a legitimate name in half, matched nothing, and (before the server learned to
6350
- // report unmatched selectors) reported a clean run of zero cases. `|` is the delimiter now; the
6351
- // comma still works when no `|` is present, so existing invocations keep behaving as before.
6352
- // `--names` may also be repeated, which needs no delimiter at all.
7506
+ // Test case names are English prose, so COMMAS occur in them naturally. `|` (or repeating --names) is the
7507
+ // only delimiter; see parseTestCaseSelectors for how a comma-bearing selector is matched.
6353
7508
  const rawNames = flags.names === undefined || flags.names === null
6354
7509
  ? []
6355
7510
  : (Array.isArray(flags.names) ? flags.names : [flags.names]);
6356
- const repeated = rawNames.length > 1;
6357
- const names = rawNames
6358
- .flatMap((value) => {
6359
- const text = String(value);
6360
- if (text.includes('|')) return text.split('|');
6361
- // Comma-splitting is the legacy behaviour, kept so existing invocations still work. It is applied
6362
- // ONLY to a single --names value: repeating the flag is already unambiguous, so splitting there
6363
- // would take a deliberate literal name back apart.
6364
- return repeated ? [text] : text.split(',');
6365
- })
6366
- .map((s) => s.trim())
6367
- .filter(Boolean);
7511
+ const names = parseTestCaseSelectors(rawNames);
6368
7512
 
6369
7513
  const api = buildAxios(baseUrl, session.token);
6370
7514
  // --as-account runs the Test AS a (descendant) subscriber account, so that account's relationship
@@ -6405,12 +7549,20 @@ async function testCommand(flags) {
6405
7549
  withStdoutRoutedToStderr(jsonOutput, () => {
6406
7550
  printSessionResolutionWarning(sessionContext);
6407
7551
  printResolvedBaseUrl(baseUrl);
7552
+ printLocalCommandContext(cwd, flags, { accountId: asAccountId || accountId, branchName, workspace, dataMode });
7553
+ printLocalStateWarnings(cwd, flags);
6408
7554
  printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
6409
7555
  printStagingLaneOwnerNotice(start.staging || {});
6410
7556
  if (start.staging && Array.isArray(start.staging.accountLanes)) {
6411
7557
  printOrphanedWorkspaceWarning(start.staging);
6412
7558
  printAccountLanes({ accountLanes: start.staging.accountLanes, branchName, workspace });
6413
7559
  }
7560
+ runWorldLines(start.world, {
7561
+ host: normalizeBaseUrl(baseUrl),
7562
+ accountId: asAccountId || accountId,
7563
+ dataMode,
7564
+ gitHead: safeGitValue(cwd, 'git rev-parse HEAD')
7565
+ }).forEach((line) => console.log(line));
6414
7566
  console.log('Test run started:', start.taskId);
6415
7567
  });
6416
7568
  runtimeState.currentTestTaskId = start.taskId;
@@ -6419,11 +7571,12 @@ async function testCommand(flags) {
6419
7571
  if (flags.watch !== 'false') {
6420
7572
  const topic = session.websocketTopic || start.websocketTopic || (session.user && String(session.user.uuid || '').replace(/-/g, ''));
6421
7573
  if (topic) {
6422
- stopWs = watchWebsocket(baseUrl, topic, start.taskId, { stderr: jsonOutput });
7574
+ stopWs = watchWebsocket(baseUrl, topic, start.taskId, { stderr: jsonOutput, verbose: flagEnabled(flags.verbose) });
6423
7575
  }
6424
7576
  }
6425
7577
 
6426
7578
  const status = await waitForStatus(api, cwd, accountId, branchName, start.taskId, session.token, dataMode);
7579
+ status.localState = localStateSummary(cwd, flags);
6427
7580
  if (stopWs) {
6428
7581
  stopWs();
6429
7582
  }
@@ -6436,6 +7589,7 @@ async function testCommand(flags) {
6436
7589
  } else {
6437
7590
  console.log('Final status:', JSON.stringify(status, null, 2));
6438
7591
  printStagingLaneOwnerNotice(status.staging || {});
7592
+ testRunSummaryLines(status).forEach((line) => console.log(line));
6439
7593
  printTestRunPivots(status);
6440
7594
  }
6441
7595
 
@@ -6505,10 +7659,14 @@ async function testCommand(flags) {
6505
7659
  unmatchedTestNames: unmatched
6506
7660
  },
6507
7661
  evidenceCategories: testEvidenceCategories(status, names),
7662
+ // What the run spent and whether that live AI was declared (withAiBudget), so an envelope can tell a
7663
+ // measurement run from a suite with missing mocks without re-reading every case.
7664
+ dependencies: testRunDependencies(status),
7665
+ summary: status.result && status.result.summary,
6508
7666
  limitations: []
6509
7667
  .concat(unmatched.length ? ['One or more requested test case selectors matched no case.'] : [])
6510
7668
  .concat(nondeterminism ? [nondeterminism.message] : []),
6511
- rawRefs: { testStatusKey: start.taskId },
7669
+ rawRefs: { testStatusKey: start.taskId, durableTestRun: status.result && status.result.durableRecord ? start.taskId : undefined },
6512
7670
  status
6513
7671
  }, { quiet: jsonOutput });
6514
7672
  }
@@ -6545,6 +7703,11 @@ async function testStatusCommand(flags) {
6545
7703
  printResolvedBaseUrl(baseUrl);
6546
7704
  console.log('Test run status:', status.status || 'unknown');
6547
7705
  console.log('Task ID:', taskId);
7706
+ // Say where this answer came from. A durable record is a finished snapshot rebuilt from the database after the
7707
+ // live status was gone; without this line a reconstructed run is indistinguishable from one still being watched.
7708
+ console.log('Source:', status.durable
7709
+ ? 'durable run record (the live status has expired; this run is final)'
7710
+ : 'live run status');
6548
7711
  console.log('Data mode:', status.dataMode || dataMode);
6549
7712
  if (status.result) {
6550
7713
  console.log('Cases:', (status.result.passed || 0) + ' passed, ' + (status.result.failed || 0) + ' failed, ' + (status.result.total || 0) + ' total');
@@ -6552,12 +7715,100 @@ async function testStatusCommand(flags) {
6552
7715
  if (status.error || status.message) {
6553
7716
  console.log('Message:', status.error || status.message);
6554
7717
  }
7718
+ if (status.world) runWorldLines(status.world, { host: normalizeBaseUrl(baseUrl) }).forEach((line) => console.log(line));
7719
+ testRunSummaryLines(status).forEach((line) => console.log(line));
7720
+ printTestRunPivots(status);
6555
7721
  if (status.status === 'failed' || (status.result && status.result.failed > 0)) {
6556
7722
  process.exitCode = 1;
6557
7723
  }
6558
7724
  return status;
6559
7725
  }
6560
7726
 
7727
+ // `test runs --test X`: the durable history of a suite, newest first. With --compare, the latest run is
7728
+ // compared case-by-case with the one before it.
7729
+ async function testRunsCommand(flags) {
7730
+ const cwd = process.cwd();
7731
+ ensureLocalState(cwd);
7732
+ const sessionContext = resolveSessionContext(cwd, flags);
7733
+ const { session, accountId } = sessionContext;
7734
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
7735
+ const api = buildAxios(baseUrl, session.token);
7736
+ const testRef = flags.test || flags['test-id'] || flags.name;
7737
+ const data = await loggedPost(api, cwd, '/cli/testRuns', {
7738
+ token: session.token,
7739
+ accountId,
7740
+ asAccountId: flags['as-account'] || flags['as-account-id'],
7741
+ testId: testRef && /^\d+$/.test(String(testRef)) ? Number(testRef) : undefined,
7742
+ testName: testRef && !/^\d+$/.test(String(testRef)) ? String(testRef) : undefined,
7743
+ max: flags.limit || flags.max || 20
7744
+ }).then((r) => r.data);
7745
+ if (!data.success) throw new Error(data.message || 'Could not list test runs');
7746
+
7747
+ if (flagEnabled(flags.compare)) {
7748
+ const runs = data.runs || [];
7749
+ if (runs.length < 2) throw new Error('--compare needs at least two recorded runs' + (testRef ? ' of ' + testRef : '') + '; found ' + runs.length);
7750
+ return testCompareCommand(Object.assign({}, flags, { base: runs[1].taskId, head: runs[0].taskId }));
7751
+ }
7752
+ if (flagEnabled(flags.json)) {
7753
+ console.log(JSON.stringify(data, null, 2));
7754
+ return data;
7755
+ }
7756
+ printSessionResolutionWarning(sessionContext);
7757
+ printResolvedBaseUrl(baseUrl);
7758
+ console.log('Recorded test runs' + (testRef ? ' for ' + testRef : '') + ' (account ' + data.accountId + '), newest first:');
7759
+ (data.runs || []).forEach((run) => {
7760
+ console.log('- ' + run.taskId + ' ' + (run.status || 'unknown') + ' ' + (run.passed || 0) + '/' + (run.total || 0) + ' passed' +
7761
+ ' ' + corpusAiLabel(run) +
7762
+ (run.durationMs ? ' ' + formatDurationMs(run.durationMs) : '') +
7763
+ ' ' + (run.dataMode || '?') + ' ' + (run.branchName || '?') + (run.workspace ? ' [ws:' + run.workspace + ']' : '') +
7764
+ (run.laneContentHash ? ' lane ' + String(run.laneContentHash).slice(0, 12) : '') +
7765
+ ' ' + formatTime(run.completedAt) + ' ' + (run.testName || ''));
7766
+ });
7767
+ if (!(data.runs || []).length) console.log('(none recorded)');
7768
+ return data;
7769
+ }
7770
+
7771
+ // `test compare --base <taskId> --head <taskId>`: per-case outcome, cost and duration deltas between two runs,
7772
+ // read from the durable records.
7773
+ async function testCompareCommand(flags) {
7774
+ const cwd = process.cwd();
7775
+ ensureLocalState(cwd);
7776
+ const sessionContext = resolveSessionContext(cwd, flags);
7777
+ const { session, accountId } = sessionContext;
7778
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
7779
+ const api = buildAxios(baseUrl, session.token);
7780
+ const baseTask = flags.base;
7781
+ const headTask = flags.head;
7782
+ if (!baseTask || !headTask) throw new Error('test compare needs --base <taskId> and --head <taskId> (or: test runs --test X --compare)');
7783
+ const fetchStatus = (taskId) => loggedPost(api, cwd, '/cli/test', {
7784
+ token: session.token, command: 'status', accountId, branchName: flags.branch || currentBranch(cwd), taskId
7785
+ }).then((r) => r.data);
7786
+ const [baseStatus, headStatus] = await Promise.all([fetchStatus(baseTask), fetchStatus(headTask)]);
7787
+ const comparison = compareTestRunResults(Object.assign({ taskId: baseTask }, baseStatus.result || {}), Object.assign({ taskId: headTask }, headStatus.result || {}));
7788
+ comparison.baseWorld = baseStatus.world || (baseStatus.result && baseStatus.result.world) || null;
7789
+ comparison.headWorld = headStatus.world || (headStatus.result && headStatus.result.world) || null;
7790
+ if (flagEnabled(flags.json)) {
7791
+ console.log(JSON.stringify(comparison, null, 2));
7792
+ return comparison;
7793
+ }
7794
+ printResolvedBaseUrl(baseUrl);
7795
+ console.log('Base ' + baseTask + ': ' + comparison.base.passed + '/' + comparison.base.total + ' passed, AI ' + formatUsd(comparison.base.cost));
7796
+ console.log('Head ' + headTask + ': ' + comparison.head.passed + '/' + comparison.head.total + ' passed, AI ' + formatUsd(comparison.head.cost));
7797
+ const worldDiff = ['laneContentHash', 'componentBranch', 'dataMode', 'workspace', 'platformSyncSha']
7798
+ .filter((key) => comparison.baseWorld && comparison.headWorld && String(comparison.baseWorld[key] || '') !== String(comparison.headWorld[key] || ''))
7799
+ .map((key) => key + ' ' + short(String(comparison.baseWorld[key] || 'none')) + ' -> ' + short(String(comparison.headWorld[key] || 'none')));
7800
+ if (worldDiff.length) console.log('World changed: ' + worldDiff.join('; '));
7801
+ if (comparison.improved.length) console.log('Improved (' + comparison.improved.length + '): ' + comparison.improved.join(', '));
7802
+ if (comparison.regressed.length) console.log('Regressed (' + comparison.regressed.length + '): ' + comparison.regressed.join(', '));
7803
+ comparison.changed.filter((c) => c.direction === 'changed').forEach((c) => console.log('Changed: ' + c.name + ' ' + c.base + ' -> ' + c.head));
7804
+ comparison.cases.filter((c) => c.head !== 'passed' && c.headReason).slice(0, 20).forEach((c) => {
7805
+ console.log(' ' + c.name + ' [' + c.head + ']: ' + String(c.headReason).slice(0, 300));
7806
+ });
7807
+ if (!comparison.changed.length) console.log('No case changed outcome.');
7808
+ return comparison;
7809
+ }
7810
+
7811
+
6561
7812
  /*
6562
7813
  ## Tokens, Tools, Verification, And Config
6563
7814
  */
@@ -6627,7 +7878,8 @@ async function tokenCommand(flags) {
6627
7878
  accountLanes: data.accountLanes,
6628
7879
  dataMode: data.dataMode || dataMode,
6629
7880
  shortBasePath: data.shortBasePath,
6630
- embeddableUrl
7881
+ embeddableUrl,
7882
+ localState: localStateSummary(cwd, flags)
6631
7883
  };
6632
7884
 
6633
7885
  // What the token will RESOLVE AS. `accountId` says which account it executes as; on a hierarchy the
@@ -6650,7 +7902,11 @@ async function tokenCommand(flags) {
6650
7902
  output.embedSnippet = data.embedSnippet;
6651
7903
  }
6652
7904
 
6653
- printSessionResolutionWarning(sessionContext);
7905
+ if (!flagEnabled(flags.json)) {
7906
+ printSessionResolutionWarning(sessionContext);
7907
+ printLocalCommandContext(cwd, flags, { accountId: output.accountId || accountId, branchName, workspace: output.workspace, dataMode: output.dataMode || dataMode });
7908
+ printLocalStateWarnings(cwd, flags);
7909
+ }
6654
7910
  console.log(JSON.stringify(output, null, 2));
6655
7911
 
6656
7912
  await appendVerificationPacket(api, cwd, session, accountId, flags, {
@@ -6666,7 +7922,7 @@ async function tokenCommand(flags) {
6666
7922
  },
6667
7923
  evidenceCategories: ['token_inspect'],
6668
7924
  token: Object.assign({}, output, { tokenKey: output.tokenKey ? '[redacted]' : undefined, embedTokenKey: output.embedTokenKey ? '[redacted]' : undefined })
6669
- });
7925
+ }, { quiet: flagEnabled(flags.json) });
6670
7926
  }
6671
7927
 
6672
7928
  async function tokenInspectCommand(flags) {
@@ -6868,6 +8124,7 @@ async function toolCommand(flags) {
6868
8124
 
6869
8125
  data.responseFile = statusResponse.responseFile;
6870
8126
  data.sessionLog = sessionJsonlFile(cwd);
8127
+ data.localState = localStateSummary(cwd, flags);
6871
8128
  // A completed run can still carry a tool-level refusal — see toolResultFailed.
6872
8129
  const polledFailed = data.toolSuccess === false || toolResultFailed(data.result);
6873
8130
  if (polledFailed) {
@@ -6890,6 +8147,8 @@ async function toolCommand(flags) {
6890
8147
  }
6891
8148
  printSessionResolutionWarning(sessionContext);
6892
8149
  printResolvedBaseUrl(baseUrl);
8150
+ printLocalCommandContext(cwd, flags, { accountId: statusAccountId, branchName: flags.branch || (stored && stored.branchName) || branchName, dataMode: data.dataMode || dataMode });
8151
+ printLocalStateWarnings(cwd, flags);
6893
8152
  console.log('Tool call status:', data.status);
6894
8153
  console.log('Call ID:', requestedCallId);
6895
8154
  console.log('Data mode:', data.dataMode || dataMode);
@@ -6944,6 +8203,7 @@ async function toolCommand(flags) {
6944
8203
  const toolFailureMessage = data.toolMessage || toolResultMessage(data.result);
6945
8204
  data.responseFile = response.responseFile;
6946
8205
  data.sessionLog = sessionJsonlFile(cwd);
8206
+ data.localState = localStateSummary(cwd, flags);
6947
8207
  if (toolFailureMessage && !data.toolMessage) data.toolMessage = toolFailureMessage;
6948
8208
 
6949
8209
  let evidenceData = data;
@@ -6952,6 +8212,8 @@ async function toolCommand(flags) {
6952
8212
  if (!jsonOutput) {
6953
8213
  printSessionResolutionWarning(sessionContext);
6954
8214
  printResolvedBaseUrl(baseUrl);
8215
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace: resolveWorkspace(cwd, flags), dataMode: data.dataMode || dataMode });
8216
+ printLocalStateWarnings(cwd, flags);
6955
8217
  console.log(asyncMode ? 'Tool call started.'
6956
8218
  : (toolFailed ? 'Tool call FAILED — the tool ran and returned an error.' : 'Tool call succeeded.'));
6957
8219
  if (toolFailed && toolFailureMessage) console.log('Tool error:', toolFailureMessage);
@@ -7020,6 +8282,202 @@ async function toolCommand(flags) {
7020
8282
  }
7021
8283
  }
7022
8284
 
8285
+ // Human output for the read-only corpus commands, from the platform's response alone.
8286
+ // How a run or a case actually exercised AI. A replayed suite and a measured one produce identical outcomes,
8287
+ // scores and metrics, so every scorecard line has to say which it was or it is not evidence of anything.
8288
+ function corpusAiLabel(row = {}) {
8289
+ // When the corpus cases themselves recorded no AI, the platform falls back to the whole suite run's counters
8290
+ // and says so with aiModeSource. Report that as "run AI ...", never as the corpus cases' own measurement.
8291
+ const fromRun = row.aiModeSource === 'run';
8292
+ const live = Number((fromRun ? row.runLiveAiCalls : row.liveAiCalls) || 0);
8293
+ const mocked = Number((fromRun ? row.runMockedAiCalls : row.mockedAiCalls) || 0);
8294
+ const calls = Number((fromRun ? row.runAiCalls : row.aiCalls) || 0);
8295
+ const prefix = fromRun ? 'run AI ' : 'AI ';
8296
+ if (live > 0) return prefix + 'live ' + formatUsd(row.liveAiCost) + ' (' + live + '/' + (calls || live) + ' live)';
8297
+ if (mocked > 0 || calls > 0) return prefix + 'MOCKED (' + (mocked || calls) + ' replayed, $0.00)';
8298
+ return 'no AI';
8299
+ }
8300
+
8301
+ function corpusLines(sub, data = {}) {
8302
+ const lines = ['Corpus: ' + (data.corpus || '?') + ' (owner account ' + (data.ownerAccountId || '?') + ')'];
8303
+ if (sub === 'cases') {
8304
+ const cases = data.cases || [];
8305
+ lines.push('Cases: ' + cases.length);
8306
+ cases.forEach((c) => lines.push('- ' + c.caseKey + (c.split ? ' [' + c.split + ']' : '') + (c.tags && c.tags.length ? ' tags ' + c.tags.join(',') : '') +
8307
+ ' expected ' + (c.hasExpected ? (c.expectedStatus || 'present') : 'none') + ' artifacts ' + (c.artifacts || []).map((a) => a.name + ':' + a.bytes + 'b').join(' ')));
8308
+ } else if (sub === 'results') {
8309
+ (data.results || []).forEach((r) => lines.push('- ' + r.caseKey + ' ' + (r.outcome || '?') + (r.score !== undefined ? ' score ' + r.score : '') +
8310
+ ' run ' + r.taskId + ' ' + corpusAiLabel(r) + (r.durationMs ? ' ' + formatDurationMs(r.durationMs) : '') +
8311
+ ' metrics ' + String(r.metricsHash || '').slice(0, 10) + ' ' + formatTime(r.recordedAt)));
8312
+ } else if (sub === 'runs') {
8313
+ (data.runs || []).forEach((r) => lines.push('- ' + r.taskId + ' ' + r.cases + ' case(s) ' + Object.keys(r.outcomes || {}).map((k) => k + ' ' + r.outcomes[k]).join(', ') +
8314
+ ' ' + corpusAiLabel(r) + ' ' + (r.dataMode || '?') + ' ' + (r.branchName || '?') + (r.workspace ? ' [ws:' + r.workspace + ']' : '') +
8315
+ (r.laneContentHash ? ' lane ' + String(r.laneContentHash).slice(0, 12) : '') + ' ' + formatTime(r.recordedAt)));
8316
+ if ((data.runs || []).some((r) => r.aiMode === 'mocked')) {
8317
+ lines.push('Runs marked AI MOCKED replayed every AI turn from aiMock - they measure the harness, not the model.');
8318
+ }
8319
+ } else if (sub === 'compare') {
8320
+ const cmp = data.comparison || {};
8321
+ lines.push('Base ' + cmp.baseTaskId + ' -> head ' + cmp.headTaskId + ': ' + Object.keys(cmp.counts || {}).map((k) => k + ' ' + cmp.counts[k]).join(', '));
8322
+ lines.push('AI mode: base ' + (cmp.baseAiMode || '?') + ', head ' + (cmp.headAiMode || '?'));
8323
+ if (cmp.aiModeChanged) lines.push('WARNING: ' + cmp.aiModeChanged);
8324
+ (cmp.cases || []).filter((c) => c.direction !== 'same').forEach((c) => {
8325
+ lines.push('- ' + c.caseKey + ' ' + c.direction + (c.base ? ' ' + c.base.outcome : '') + (c.head ? ' -> ' + c.head.outcome : '') +
8326
+ (c.expectedChanged ? ' (EXPECTED ANSWER CHANGED between runs)' : ''));
8327
+ (c.changedMetrics || []).slice(0, 8).forEach((m) => lines.push(' ' + m.metric + ': ' + JSON.stringify(m.base) + ' -> ' + JSON.stringify(m.head)));
8328
+ if (c.head && c.head.outcomeReason && c.head.outcome !== 'passed') lines.push(' reason: ' + String(c.head.outcomeReason).slice(0, 300));
8329
+ });
8330
+ } else if (sub === 'retire') {
8331
+ lines.push('Result: ' + Object.keys(data.counts || {}).map((k) => k + ' ' + data.counts[k]).join(', '));
8332
+ (data.cases || []).forEach((c) => lines.push('- ' + c.caseKey + ' ' + c.status + (c.message ? ' ' + c.message : (c.changed === false ? ' (already in that state)' : ''))));
8333
+ // Only explain what retirement means when something was actually retired; after a wholly refused call the
8334
+ // reader needs the refusal, not a description of a state nothing reached.
8335
+ if ((data.cases || []).some((c) => c.status === 'retired' || c.status === 'restored')) {
8336
+ 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.');
8337
+ }
8338
+ } else if (sub === 'consistency') {
8339
+ const con = data.consistency || {};
8340
+ lines.push('Case ' + con.caseKey + ': ' + con.runs + ' run(s), ' + (con.stable ? 'STABLE (identical metrics every run)' : (con.variants || []).length + ' distinct metric results') +
8341
+ (con.aiModes && con.aiModes.length ? ' AI ' + con.aiModes.join('+') : ''));
8342
+ if (con.note) lines.push('WARNING: ' + con.note);
8343
+ (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(', ')));
8344
+ }
8345
+ return lines;
8346
+ }
8347
+
8348
+ // `corpus import|cases|results|runs|compare|consistency`: test corpora through the platform's own corpus(name).
8349
+ async function corpusCommand(flags, subcommand) {
8350
+ const cwd = process.cwd();
8351
+ ensureLocalState(cwd);
8352
+ const sessionContext = resolveSessionContext(cwd, flags);
8353
+ const { session, accountId } = sessionContext;
8354
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
8355
+ const branchName = flags.branch || currentBranch(cwd);
8356
+ const workspace = resolveWorkspace(cwd, flags);
8357
+ const dataMode = hasExplicitDataModeFlag(flags) ? resolveDataMode(flags, null) : DEFAULT_DATA_MODE;
8358
+ const jsonOutput = flagEnabled(flags.json);
8359
+ const sub = String(subcommand || 'cases').toLowerCase();
8360
+ const emit = jsonOutput ? console.error : console.log;
8361
+ const api = buildAxios(baseUrl, session.token, parsePositiveInt(flags['timeout-ms'], 300000));
8362
+ const base = {
8363
+ token: session.token,
8364
+ accountId,
8365
+ asAccountId: flags['as-account'] || flags['as-account-id'],
8366
+ variantBranch: flags['variant-branch'],
8367
+ branchName,
8368
+ workspace,
8369
+ dataMode
8370
+ };
8371
+ const post = async (payload) => {
8372
+ try {
8373
+ return await loggedPost(api, cwd, '/cli/corpus', Object.assign({}, base, payload)).then((r) => r.data);
8374
+ } catch (err) {
8375
+ const data = err && err.response && err.response.data;
8376
+ if (data && data.message) throw new Error(data.message);
8377
+ throw err;
8378
+ }
8379
+ };
8380
+
8381
+ if (sub === 'import') {
8382
+ const manifestFile = flags.manifest || flags.file;
8383
+ if (!manifestFile) throw new Error('corpus import requires --manifest <manifest.json>');
8384
+ const resolved = path.resolve(cwd, String(manifestFile));
8385
+ const manifest = JSON.parse(fs.readFileSync(resolved, 'utf8'));
8386
+ const corpusName = flags.corpus || manifest.corpus;
8387
+ if (!corpusName) throw new Error('corpus import needs a corpus name: "corpus" in the manifest or --corpus');
8388
+ const entries = corpusManifestEntries(manifest, path.dirname(resolved));
8389
+ if (!entries.length) throw new Error('The manifest has no cases');
8390
+
8391
+ withStdoutRoutedToStderr(jsonOutput, () => {
8392
+ printSessionResolutionWarning(sessionContext);
8393
+ printResolvedBaseUrl(baseUrl);
8394
+ printLocalCommandContext(cwd, flags, { accountId: flags['as-account'] || accountId, branchName, workspace, dataMode });
8395
+ printProdDataBanner({ dataMode, accountId: flags['as-account'] || accountId, baseUrl, operation: 'corpus import', mutating: true });
8396
+ });
8397
+ const totalBytes = entries.reduce((sum, e) => sum + e.artifacts.reduce((n, a) => n + a.bytes, 0), 0);
8398
+ emit('Importing ' + entries.length + ' case(s) into corpus "' + corpusName + '", ' +
8399
+ entries.reduce((n, e) => n + e.artifacts.length, 0) + ' artifact(s), ' + totalBytes + ' bytes (artifacts in the ' + dataMode + ' lane)');
8400
+
8401
+ const responses = [];
8402
+ for (const batch of batchCorpusEntries(entries)) {
8403
+ const cases = batch.map((entry) => Object.assign({}, entry, {
8404
+ artifacts: entry.artifacts.map((a) => ({
8405
+ name: a.name,
8406
+ fileName: a.fileName,
8407
+ contentType: a.contentType,
8408
+ // Read and encoded only here, per batch. The session log records sizes and hashes, never this value.
8409
+ contentBase64: fs.readFileSync(a.path).toString('base64')
8410
+ }))
8411
+ }));
8412
+ responses.push(await post({ command: 'import', corpus: corpusName, confirmProd: flagEnabled(flags['confirm-prod']), cases }));
8413
+ }
8414
+ const body = corpusImportPacketBody(responses);
8415
+ if (!body.success) process.exitCode = 1;
8416
+ if (jsonOutput) {
8417
+ console.log(JSON.stringify(body, null, 2));
8418
+ } else {
8419
+ console.log('Corpus: ' + body.corpus + ' (owner account ' + body.ownerAccountId + ')');
8420
+ console.log('Result: ' + Object.keys(body.counts).map((k) => k + ' ' + body.counts[k]).join(', '));
8421
+ body.cases.forEach((c) => {
8422
+ console.log('- ' + c.caseKey + ' ' + c.status + (c.changedKeys && c.changedKeys.length && c.status === 'updated' ? ' changed: ' + c.changedKeys.join(', ') : '') + (c.message ? ' ' + c.message : ''));
8423
+ (c.artifacts || []).forEach((a) => console.log(' ' + a.name + ' object ' + a.objectId + ' ' + a.bytes + ' bytes sha256 ' + String(a.sha256).slice(0, 12) + ' ' + (a.created ? 'stored' : 'already stored')));
8424
+ });
8425
+ // Only point at the next step when something actually landed. Printing it after a wholly failed import
8426
+ // told the reader to go use a corpus that has no cases in it.
8427
+ if (body.success) {
8428
+ console.log('Use it in a Test with corpus(\'' + body.corpus + '\').cases(); compare runs with remits-cli corpus compare.');
8429
+ } else {
8430
+ console.log('Import did not fully succeed - fix the case(s) above and re-run; import is idempotent, so unchanged cases stay unchanged.');
8431
+ }
8432
+ }
8433
+ await appendVerificationPacket(api, cwd, session, accountId, flags, {
8434
+ type: 'corpus_import',
8435
+ success: body.success,
8436
+ claim: 'Corpus ' + body.corpus + ' import: ' + Object.keys(body.counts).map((k) => k + ' ' + body.counts[k]).join(', '),
8437
+ world: Object.assign({}, body.world || {}, { host: normalizeBaseUrl(baseUrl), branchName, workspace, dataMode }),
8438
+ revision: collectVerificationSource(cwd, flags),
8439
+ evidenceCategories: ['corpus_import'],
8440
+ corpusImport: { corpus: body.corpus, ownerAccountId: body.ownerAccountId, counts: body.counts, cases: body.cases }
8441
+ }, { quiet: jsonOutput });
8442
+ return body;
8443
+ }
8444
+
8445
+ const corpusName = flags.corpus || (flags._ && flags._[2]);
8446
+ if (!corpusName) throw new Error('corpus ' + sub + ' requires --corpus <name>');
8447
+ let data;
8448
+ if (sub === 'cases') {
8449
+ const tags = parseCorpusFilterValues(flags, ['tag', 'tags']);
8450
+ const keys = parseCorpusFilterValues(flags, ['key', 'keys', 'case', 'case-key']);
8451
+ 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 });
8452
+ } else if (sub === 'results') {
8453
+ data = await post({ command: 'results', corpus: corpusName, caseKey: flags.case || flags['case-key'], taskId: flags['task-id'], limit: flags.limit || 50 });
8454
+ } else if (sub === 'runs') {
8455
+ data = await post({ command: 'runs', corpus: corpusName, limit: flags.limit || 20 });
8456
+ } else if (sub === 'compare') {
8457
+ if (!flags.base || !flags.head) throw new Error('corpus compare requires --base <taskId> --head <taskId>');
8458
+ data = await post({ command: 'compare', corpus: corpusName, base: flags.base, head: flags.head });
8459
+ } else if (sub === 'consistency') {
8460
+ const caseKey = flags.case || flags['case-key'];
8461
+ if (!caseKey) throw new Error('corpus consistency requires --case <caseKey>');
8462
+ data = await post({ command: 'consistency', corpus: corpusName, caseKey, limit: flags.limit || 20 });
8463
+ } else if (sub === 'retire') {
8464
+ const retireKeys = parseCorpusFilterValues(flags, ['key', 'keys', 'case', 'case-key']);
8465
+ if (!retireKeys.length) throw new Error('corpus retire requires --case <caseKey> (repeatable) or --keys a,b,c');
8466
+ data = await post({ command: 'retire', corpus: corpusName, keys: retireKeys, restore: flagEnabled(flags.restore) });
8467
+ if (data && data.success === false) process.exitCode = 1;
8468
+ } else {
8469
+ throw new Error('Unknown corpus subcommand: ' + sub + ' (use import, cases, results, runs, compare, consistency or retire)');
8470
+ }
8471
+ if (jsonOutput) {
8472
+ console.log(JSON.stringify(data, null, 2));
8473
+ return data;
8474
+ }
8475
+ printSessionResolutionWarning(sessionContext);
8476
+ printResolvedBaseUrl(baseUrl);
8477
+ corpusLines(sub, data).forEach((line) => console.log(line));
8478
+ return data;
8479
+ }
8480
+
7023
8481
  async function verifyCommand(flags, subcommand) {
7024
8482
  const cwd = process.cwd();
7025
8483
  ensureLocalState(cwd);
@@ -7038,6 +8496,8 @@ async function verifyCommand(flags, subcommand) {
7038
8496
  const active = readActiveVerificationEnvelope(cwd, activeContext);
7039
8497
  printSessionResolutionWarning(sessionContext);
7040
8498
  printResolvedBaseUrl(baseUrl);
8499
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace, dataMode });
8500
+ printLocalStateWarnings(cwd, flags);
7041
8501
  console.log('Context:', 'account=' + accountId + ', branch=' + branchName + ', workspace=' + (workspace || 'shared') + ', dataMode=' + dataMode);
7042
8502
  if (!active) {
7043
8503
  const legacy = readActiveVerificationEnvelope(cwd);
@@ -7058,6 +8518,8 @@ async function verifyCommand(flags, subcommand) {
7058
8518
  if (!envelopeId) throw new Error('Usage: remits-cli verify use <envelopeId>');
7059
8519
  writeActiveVerificationEnvelope(cwd, envelopeId, activeContext);
7060
8520
  console.log('Active verification envelope:', envelopeId);
8521
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace, dataMode });
8522
+ printLocalStateWarnings(cwd, flags);
7061
8523
  console.log('Context:', 'account=' + accountId + ', branch=' + branchName + ', workspace=' + (workspace || 'shared') + ', dataMode=' + dataMode);
7062
8524
  return;
7063
8525
  }
@@ -7070,6 +8532,7 @@ async function verifyCommand(flags, subcommand) {
7070
8532
  } else {
7071
8533
  writeActiveVerificationEnvelope(cwd, null, activeContext);
7072
8534
  console.log('Verification envelope cleared for this context.');
8535
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace, dataMode });
7073
8536
  console.log('Context:', 'account=' + accountId + ', branch=' + branchName + ', workspace=' + (workspace || 'shared') + ', dataMode=' + dataMode);
7074
8537
  }
7075
8538
  return;
@@ -7121,6 +8584,8 @@ async function verifyCommand(flags, subcommand) {
7121
8584
  writeLocalVerificationEnvelope(cwd, envelope);
7122
8585
  writeActiveVerificationEnvelope(cwd, envelope.envelopeId, activeContext);
7123
8586
  console.log('Verification envelope started:', envelope.envelopeId);
8587
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace, dataMode });
8588
+ printLocalStateWarnings(cwd, flags);
7124
8589
  console.log('Target:', 'account=' + accountId + ', branch=' + branchName + ', workspace=' + (workspace || 'shared') + ', dataMode=' + dataMode);
7125
8590
  printAcceptanceWarnings(envelope);
7126
8591
  printEnvelopeWarnings(envelope);
@@ -7984,7 +9449,10 @@ function detectIssueSignals(repoEntries, globalFiles) {
7984
9449
  }
7985
9450
 
7986
9451
  for (const repo of repoEntries || []) {
7987
- const sessionLogFile = (repo.files || []).find((file) => file.label === '.remits-cli/session log (tail)');
9452
+ const sessionLogFile = (repo.files || []).find((file) =>
9453
+ file.label === '.remits-cli/active-actor/session log (tail)' ||
9454
+ file.label === '.remits-cli/session log (tail)' ||
9455
+ file.label === '.remits-cli/legacy/session log (tail)');
7988
9456
  if (!sessionLogFile || !sessionLogFile.path || !fs.existsSync(sessionLogFile.path)) {
7989
9457
  continue;
7990
9458
  }
@@ -8180,23 +9648,41 @@ function buildGlobalFileSnapshot() {
8180
9648
  function buildRepoSnapshot(entry) {
8181
9649
  const directory = entry.directory;
8182
9650
  const localPaths = localStatePaths(directory);
9651
+ const actors = listActorDirectories(directory);
8183
9652
  const currentSessionName = fs.existsSync(localPaths.currentSessionFile)
8184
9653
  ? fs.readFileSync(localPaths.currentSessionFile, 'utf8').trim()
8185
9654
  : '';
8186
9655
  const currentSessionLog = currentSessionName
8187
9656
  ? path.join(localPaths.sessionsDir, currentSessionName + '.jsonl')
8188
9657
  : null;
9658
+ const legacySessionName = fs.existsSync(localPaths.legacy.currentSessionFile)
9659
+ ? fs.readFileSync(localPaths.legacy.currentSessionFile, 'utf8').trim()
9660
+ : '';
9661
+ const legacySessionLog = legacySessionName
9662
+ ? path.join(localPaths.legacy.sessionsDir, legacySessionName + '.jsonl')
9663
+ : null;
8189
9664
  const repoFiles = [
8190
9665
  { label: 'account-info.json', path: entry.accountInfoPath || path.join(directory, 'account-info.json'), mode: 'json' },
8191
9666
  { label: 'account-hierarchy.json', path: path.join(directory, 'account-hierarchy.json'), mode: 'json' },
8192
- { label: '.remits-cli/current-session.txt', path: localPaths.currentSessionFile, mode: 'text' },
8193
- { label: '.remits-cli/tools/tools.json', path: path.join(localPaths.toolsDir, 'tools.json'), mode: 'json' },
8194
- { label: '.remits-cli/session log (tail)', path: currentSessionLog, mode: 'tail' }
9667
+ { label: '.remits-cli/active-actor/current-session.txt', path: localPaths.currentSessionFile, mode: 'text' },
9668
+ { label: '.remits-cli/shared/tools/tools.json', path: path.join(localPaths.toolsDir, 'tools.json'), mode: 'json' },
9669
+ { label: '.remits-cli/active-actor/session log (tail)', path: currentSessionLog, mode: 'tail' },
9670
+ { label: '.remits-cli/legacy/current-session.txt', path: localPaths.legacy.currentSessionFile, mode: 'text' },
9671
+ { label: '.remits-cli/legacy/session log (tail)', path: legacySessionLog, mode: 'tail' }
8195
9672
  ];
8196
9673
 
8197
9674
  return {
8198
9675
  ...entry,
8199
9676
  exists: fs.existsSync(directory),
9677
+ localState: {
9678
+ activeActor: localPaths.actor,
9679
+ activeActorDir: localPaths.actorDir,
9680
+ actors,
9681
+ foreignActors: actors.filter((actor) => !actor.current),
9682
+ legacyStatePresent: fs.existsSync(localPaths.legacy.currentSessionFile) ||
9683
+ fs.existsSync(localPaths.legacy.sessionsDir) ||
9684
+ fs.existsSync(localPaths.legacy.toolResponsesDir)
9685
+ },
8200
9686
  files: repoFiles.map((file) => {
8201
9687
  const stat = file.path ? fileStat(file.path) : null;
8202
9688
  let content = null;
@@ -12928,6 +14414,8 @@ function printComponentsHelp(subcommand) {
12928
14414
  function printTestHelp() {
12929
14415
  console.log('Usage: remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none] [--json]');
12930
14416
  console.log(' remits-cli test status --task-id <taskId> [--base-url URL] [--account-id ID] [--branch stagingScope] [--data-mode test|prod] [--json]');
14417
+ console.log(' remits-cli test runs [--test <id|name>] [--limit 20] [--compare] [--json] # durable run history');
14418
+ console.log(' remits-cli test compare --base <taskId> --head <taskId> [--json] # per-case outcome/cost deltas');
12931
14419
  console.log('');
12932
14420
  console.log('Runs a Test component against the staged/variant world for this checkout.');
12933
14421
  console.log('Examples:');
@@ -12945,6 +14433,38 @@ function printTestHelp() {
12945
14433
  console.log(' value with --variant-branch none when existing staged entries would shadow DB rows.');
12946
14434
  console.log(' --json prints only the final status JSON to stdout; banners and progress go to stderr.');
12947
14435
  console.log(' --data-mode prod intentionally targets live production data.');
14436
+ console.log(' Every finished run is recorded durably: test status keeps working after the live status');
14437
+ console.log(' expires, and test runs / test compare read those records.');
14438
+ }
14439
+
14440
+
14441
+ function printCorpusHelp() {
14442
+ console.log('Usage: remits-cli corpus import --manifest corpus-manifest.json [--corpus <name>] [--as-account ID] [--data-mode test|prod --confirm-prod] [--json]');
14443
+ console.log(' remits-cli corpus cases --corpus <name> [--split dev] [--tag name] [--key caseKey] [--include-values] [--include-retired] [--json]');
14444
+ console.log(' remits-cli corpus results --corpus <name> [--case <caseKey>] [--task-id <taskId>] [--limit 50] [--json]');
14445
+ console.log(' remits-cli corpus runs --corpus <name> [--limit 20] [--json]');
14446
+ console.log(' remits-cli corpus compare --corpus <name> --base <taskId> --head <taskId> [--json]');
14447
+ console.log(' remits-cli corpus consistency --corpus <name> --case <caseKey> [--limit 20] [--json]');
14448
+ console.log(' remits-cli corpus retire --corpus <name> --case <caseKey> [--case ...] [--restore] [--json]');
14449
+ console.log('');
14450
+ console.log('A corpus is a named, durable set of cases an evaluation-style Test runs again and again (front stage:');
14451
+ console.log('corpus(name)). Cases live in a platform table owned by one account and visible to the accounts beneath');
14452
+ console.log('it, whatever the data lane or branch. Case files are stored once as immutable FILE source Objects.');
14453
+ console.log('Measurements are recorded by Test runs and read back with results / runs / compare / consistency.');
14454
+ console.log('');
14455
+ console.log(' - import is idempotent by caseKey: created | updated (with changed keys) | unchanged; identical artifact');
14456
+ console.log(' bytes are not stored twice. Artifacts are created in the --data-mode lane (default test).');
14457
+ console.log(' - Output and session logs carry keys, statuses, hashes, sizes and object ids, never artifact bytes;');
14458
+ console.log(' cases hides expected/source values unless --include-values.');
14459
+ console.log(' - cases filters by --split, repeated/comma-separated --tag/--tags, and --key/--keys before applying --limit.');
14460
+ console.log(' - runs / results / compare label each run AI live, AI MOCKED or no AI. A suite whose AI turns were all');
14461
+ console.log(' replayed from aiMock produces the same outcomes and metrics as a measured one, so read that label');
14462
+ console.log(' before treating a green run as evidence; compare warns when base and head used different AI modes.');
14463
+ console.log(' - retire drops a case from future runs without deleting it: existing measurements stay readable.');
14464
+ console.log('');
14465
+ console.log('Manifest (paths relative to the manifest):');
14466
+ console.log(' {"corpus":"invoice-totals","cases":[{"caseKey":"inv-001","split":"dev","tags":["multi-page"],');
14467
+ console.log(' "expected":{"total":1234.5},"source":{"note":"copied from ..."},"artifacts":[{"name":"source","path":"inv-001.pdf"}]}]}');
12948
14468
  }
12949
14469
 
12950
14470
  function printToolHelp() {
@@ -13018,6 +14538,7 @@ async function main() {
13018
14538
  migrateSessionIfNeeded();
13019
14539
  const originalArgv = process.argv.slice(2);
13020
14540
  const args = parseArgs(originalArgv);
14541
+ configureLocalActor(args);
13021
14542
  const [command, subcommand] = args._;
13022
14543
  const wantsHelp = args.help === true || subcommand === 'help' || subcommand === '--help' || args._.includes('--help');
13023
14544
  const wantsJson = flagEnabled(args.json);
@@ -13048,7 +14569,7 @@ async function main() {
13048
14569
  try { updateAccountRepoIndex(process.cwd()); } catch (_) { /* not in account repo */ }
13049
14570
  // Auto-start the background service if not already running.
13050
14571
  // Skip for lifecycle subcommands and help.
13051
- const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami' || command === 'agent' || command === 'ticket';
14572
+ const isLifecycleCmd = command === 'listen' || command === 'start' || command === 'stop' || command === 'status' || command === 'whoami' || command === 'doctor' || command === 'agent' || command === 'ticket';
13052
14573
  const isHelpCmd = !command || command === 'help' || command === '--help' || wantsHelp;
13053
14574
  if (!wantsJson && !isHelpCmd && !isLifecycleCmd && !isListenerRunning()) {
13054
14575
  try {
@@ -13071,7 +14592,7 @@ async function main() {
13071
14592
 
13072
14593
  if (!command || command === 'help' || command === '--help') {
13073
14594
  console.log('Usage: remits-cli <command>');
13074
- console.log('Global options: --no-auto-update');
14595
+ console.log('Global options: --no-auto-update, --local-agent NAME');
13075
14596
  console.log(' remits-cli auth [--base-url URL] [--account-id ID] [--port 8765] [--data-mode test|prod]');
13076
14597
  console.log(' remits-cli sessions [list|remove] [--account-id ID] [--base-url URL] [--data-mode test|prod]');
13077
14598
  console.log(' remits-cli config [set] [--agent claude|codex|gemini]');
@@ -13084,6 +14605,7 @@ async function main() {
13084
14605
  console.log(' remits-cli start [--foreground true] [--port 8787] # human dashboard in the browser');
13085
14606
  console.log(' remits-cli stop');
13086
14607
  console.log(' remits-cli status [--base-url URL] [--account-id ID] [--data-mode test|prod] [--json]');
14608
+ console.log(' remits-cli doctor local-state [--json] # actor/workspace/local state hygiene');
13087
14609
  console.log(' remits-cli whoami [--base-url URL] [--account-id ID] [--data-mode test|prod] [--json]');
13088
14610
  console.log(' remits-cli listen [stop|status] [--foreground true] # compatibility alias');
13089
14611
  console.log(' remits-cli data-mode [set test|prod]');
@@ -13108,6 +14630,10 @@ async function main() {
13108
14630
  console.log(' remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk');
13109
14631
  console.log(' remits-cli components branch <name> --retire [--force] # delete the branch\'s overlays');
13110
14632
  console.log(' remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none] [--json]');
14633
+ console.log(' remits-cli test runs [--test <id|name>] [--compare] # durable run history; compare latest two');
14634
+ console.log(' remits-cli test compare --base <taskId> --head <taskId>');
14635
+ console.log(' remits-cli corpus import --manifest corpus-manifest.json # seed a test corpus: cases + artifacts');
14636
+ console.log(' remits-cli corpus runs|results|compare|consistency --corpus <name> # measurements across Test runs');
13111
14637
  console.log(' remits-cli token [--base-url URL] [--branch BRANCH] [--path embeddable/path] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none]');
13112
14638
  console.log(' remits-cli token inspect --token <token|tokenKey|URL> [--base-url URL] [--account-id ID]');
13113
14639
  console.log('');
@@ -13278,6 +14804,11 @@ async function main() {
13278
14804
  return;
13279
14805
  }
13280
14806
 
14807
+ if (command === 'doctor') {
14808
+ await doctorCommand(args, subcommand);
14809
+ return;
14810
+ }
14811
+
13281
14812
  if (command === 'whoami') {
13282
14813
  await whoamiCommand(args);
13283
14814
  return;
@@ -13324,6 +14855,23 @@ async function main() {
13324
14855
  await testStatusCommand(args);
13325
14856
  return;
13326
14857
  }
14858
+ if (command === 'test' && (subcommand === 'runs' || subcommand === 'history')) {
14859
+ await testRunsCommand(args);
14860
+ return;
14861
+ }
14862
+ if (command === 'test' && subcommand === 'compare') {
14863
+ await testCompareCommand(args);
14864
+ return;
14865
+ }
14866
+
14867
+ if (command === 'corpus') {
14868
+ if (wantsHelp || !subcommand) {
14869
+ printCorpusHelp();
14870
+ process.exit(0);
14871
+ }
14872
+ await corpusCommand(args, subcommand);
14873
+ return;
14874
+ }
13327
14875
 
13328
14876
  if (command === 'token') {
13329
14877
  await tokenCommand(args);