@remits/remits-cli 0.1.129 → 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
- - L1448 Account Repos, Guide Sync, And Platform Repo
10
- - L2106 Component Discovery And HTTP Logging
11
- - L2598 Skill Delivery And TOC Resolution
12
- - L2908 Auth And Component Staging
13
- - L3506 Component Summaries, Status, And Sync Gates
14
- - L5457 Branches, Promotion, Commit, And Test Runs
15
- - L6682 Tokens, Tools, Verification, And Config
16
- - L7598 Service Dashboard And WebSocket Listener
17
- - L9693 Agent And Ticket Workflows
18
- - L12751 Help, Auto Update, And Command Dispatch
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);
@@ -1144,6 +1682,29 @@ function syncEvidencePayload(sync) {
1144
1682
  return Object.assign({}, sync, { syncResults: trimmedResults });
1145
1683
  }
1146
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
+
1147
1708
  async function appendVerificationPacket(api, cwd, session, accountId, flags, packet, options = {}) {
1148
1709
  const context = activeVerificationContext(cwd, flags, session, accountId);
1149
1710
  const envelopeId = verificationEnvelopeIdForCommand(cwd, flags, context);
@@ -1163,7 +1724,16 @@ async function appendVerificationPacket(api, cwd, session, accountId, flags, pac
1163
1724
  });
1164
1725
  if (response.packet) writeLocalVerificationPacket(cwd, envelopeId, response.packet);
1165
1726
  if (response.envelope) writeLocalVerificationEnvelope(cwd, response.envelope);
1166
- 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
+ }
1167
1737
  return response;
1168
1738
  } catch (err) {
1169
1739
  if (!options.quiet) {
@@ -2114,11 +2684,75 @@ function resolveSessionContext(cwd, flags) {
2114
2684
  * file there is no id, so the two can disagree (`new_PDFStatement` vs `name: PDF Statement`), and the file
2115
2685
  * identity is the only thing both the payload and the git changed set agree on.
2116
2686
  */
2117
- function withFileKey(target, key) {
2687
+ function withFileKey(target, key, filePaths) {
2118
2688
  Object.defineProperty(target, 'fileKey', { value: key, enumerable: false, configurable: true });
2689
+ if (filePaths) {
2690
+ addHiddenFilePaths(target, filePaths);
2691
+ }
2692
+ return target;
2693
+ }
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
+ }
2119
2705
  return target;
2120
2706
  }
2121
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
+
2122
2756
  function collectComponents(cwd) {
2123
2757
  const mapping = {
2124
2758
  schemas: 'schema',
@@ -2170,6 +2804,7 @@ function collectComponents(cwd) {
2170
2804
  }
2171
2805
  const component = byKey.get(key);
2172
2806
  const filePath = path.join(dir, fileName);
2807
+ addHiddenFilePaths(component, path.relative(cwd, filePath).replace(/\\/g, '/'));
2173
2808
  const content = fs.readFileSync(filePath, 'utf8');
2174
2809
 
2175
2810
  if (ext === 'meta') {
@@ -2233,7 +2868,7 @@ function collectComponents(cwd) {
2233
2868
  category: 'default',
2234
2869
  prompt: fs.readFileSync(rootReadme, 'utf8'),
2235
2870
  metadataAuthoritative: true
2236
- }, 'prompt:name:readme');
2871
+ }, 'prompt:name:readme', 'README.md');
2237
2872
  const fingerprint = {};
2238
2873
  Object.keys(component).sort().forEach((key) => {
2239
2874
  if (key !== 'hash') {
@@ -2244,7 +2879,13 @@ function collectComponents(cwd) {
2244
2879
  components.push(component);
2245
2880
  }
2246
2881
 
2247
- 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;
2248
2889
  }
2249
2890
 
2250
2891
  function componentPathInfo(cwd, filePath) {
@@ -2534,6 +3175,11 @@ async function loggedPost(api, cwd, endpoint, payload, options = {}) {
2534
3175
  let responseData = null;
2535
3176
  let error = null;
2536
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
+ }
2537
3183
 
2538
3184
  try {
2539
3185
  responseData = await api.post(endpoint, payload).then((r) => r.data);
@@ -2551,15 +3197,19 @@ async function loggedPost(api, cwd, endpoint, payload, options = {}) {
2551
3197
  const status = error ? 'error' : 'success';
2552
3198
  const responseForLog = responseFile
2553
3199
  ? { responseFile, note: 'Tool response stored externally' }
2554
- : sanitizeForLog(responseData);
3200
+ : summarizeResponseForLog(responseData);
2555
3201
  appendSessionLog(cwd, {
2556
3202
  ts: new Date().toISOString(),
2557
3203
  requestId,
2558
3204
  endpoint,
2559
3205
  method: 'POST',
3206
+ actor: actor.id,
3207
+ actorSource: actor.source,
2560
3208
  status,
2561
3209
  durationMs: Date.now() - started,
2562
- request: sanitizeForLog(payload),
3210
+ requestSummary: summarizeRequestForLog(payload),
3211
+ unsafePayloadLogging: unsafePayloadLog,
3212
+ request: requestPayloadForSessionLog(payload),
2563
3213
  response: responseForLog,
2564
3214
  error: error ? describeError(error) : null
2565
3215
  });
@@ -2572,6 +3222,7 @@ async function loggedGet(api, cwd, endpoint, params = {}) {
2572
3222
  const started = Date.now();
2573
3223
  let responseData = null;
2574
3224
  let error = null;
3225
+ const actor = resolveLocalActor();
2575
3226
  try {
2576
3227
  responseData = await api.get(endpoint, { params }).then((r) => r.data);
2577
3228
  return { data: responseData, requestId };
@@ -2585,10 +3236,13 @@ async function loggedGet(api, cwd, endpoint, params = {}) {
2585
3236
  requestId,
2586
3237
  endpoint,
2587
3238
  method: 'GET',
3239
+ actor: actor.id,
3240
+ actorSource: actor.source,
2588
3241
  status: error ? 'error' : 'success',
2589
3242
  durationMs: Date.now() - started,
2590
- request: sanitizeForLog(params),
2591
- response: sanitizeForLog(responseData),
3243
+ requestSummary: summarizeRequestForLog(params),
3244
+ request: requestPayloadForSessionLog(params),
3245
+ response: summarizeResponseForLog(responseData),
2592
3246
  error: error ? describeError(error) : null
2593
3247
  });
2594
3248
  }
@@ -3128,6 +3782,7 @@ async function pushComponentsCommand(flags) {
3128
3782
  const emptyWorksetPolicy = normalizeEmptyWorksetPolicy(flags);
3129
3783
 
3130
3784
  let components = collectComponents(cwd);
3785
+ const componentDiscoveryWarnings = Array.isArray(components.discoveryWarnings) ? components.discoveryWarnings : [];
3131
3786
  // Named the way the payload is named, BEFORE anything compares the two — see alignChangedSetWithComponents.
3132
3787
  const changedFromWorkingTree = alignChangedSetWithComponents(changedFromGit, components);
3133
3788
  // Changes git reported that a component payload cannot carry — a deleted component file has nothing to
@@ -3187,7 +3842,9 @@ async function pushComponentsCommand(flags) {
3187
3842
  : null,
3188
3843
  changedFromWorkingTree: [],
3189
3844
  changedFromWorkingTreeAvailable: true,
3190
- unrepresentableChanges: unstageable
3845
+ unrepresentableChanges: unstageable,
3846
+ componentDiscoveryWarnings,
3847
+ localState: localStateSummary(cwd, flags)
3191
3848
  };
3192
3849
  if (flagEnabled(flags.json)) {
3193
3850
  console.log(JSON.stringify(emptyResponse, null, 2));
@@ -3199,6 +3856,9 @@ async function pushComponentsCommand(flags) {
3199
3856
  console.log(' or re-run with `--empty-workset clear` if an empty lane is what you meant.');
3200
3857
  }
3201
3858
  printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
3859
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace, dataMode });
3860
+ printLocalStateWarnings(cwd, flags);
3861
+ printComponentDiscoveryWarnings(componentDiscoveryWarnings);
3202
3862
  printUnrepresentableChanges(unstageable);
3203
3863
  }
3204
3864
  return emptyResponse;
@@ -3287,6 +3947,8 @@ async function pushComponentsCommand(flags) {
3287
3947
  response.changedFromWorkingTreeAvailable = changedFromWorkingTree !== null;
3288
3948
  response.requestedStageMode = stageMode;
3289
3949
  response.unrepresentableChanges = unstageable;
3950
+ response.componentDiscoveryWarnings = componentDiscoveryWarnings;
3951
+ response.localState = localStateSummary(cwd, flags);
3290
3952
  if (response.success === false) {
3291
3953
  // A 200 that says `success:false`. The 422 path throws out of stageOrRefuse above and never lands
3292
3954
  // here, so this stays as the belt-and-braces case — print whatever detail came with it first.
@@ -3320,6 +3982,9 @@ async function pushComponentsCommand(flags) {
3320
3982
 
3321
3983
  printSessionResolutionWarning(sessionContext);
3322
3984
  printResolvedBaseUrl(baseUrl);
3985
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace, dataMode: response.dataMode || dataMode });
3986
+ printLocalStateWarnings(cwd, flags);
3987
+ printComponentDiscoveryWarnings(componentDiscoveryWarnings);
3323
3988
  printStagingLane(response.branchName || branchName, response.workspace || workspace, workspaceSource(cwd, flags));
3324
3989
  console.log('Data mode:', response.dataMode || dataMode);
3325
3990
  printComponentPolicy(response);
@@ -3522,6 +4187,15 @@ function printComponentPolicy(response) {
3522
4187
  (policy.overrides || []).forEach((id) => console.log('Policy override recorded for rule: ' + id));
3523
4188
  }
3524
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
+
3525
4199
  /**
3526
4200
  * What the platform compiled, and what it could not.
3527
4201
  *
@@ -4091,6 +4765,8 @@ async function workspaceCommand(flags) {
4091
4765
  const branchName = flags.branch || currentBranch(cwd);
4092
4766
  const workspace = resolveWorkspace(cwd, flags);
4093
4767
  printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
4768
+ printLocalActor(cwd, flags);
4769
+ printLocalStateWarnings(cwd, flags);
4094
4770
  console.log('');
4095
4771
  console.log('Precedence: --workspace > REMITS_WORKSPACE > .remits-cli/workspace > shared default lane');
4096
4772
  console.log('');
@@ -4100,6 +4776,74 @@ async function workspaceCommand(flags) {
4100
4776
  console.log(' remits-cli components status see every lane staged on this branch');
4101
4777
  }
4102
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
+
4103
4847
  async function statusComponentsCommand(flags) {
4104
4848
  const cwd = process.cwd();
4105
4849
  ensureLocalState(cwd);
@@ -4140,6 +4884,7 @@ async function statusComponentsCommand(flags) {
4140
4884
  isAncestor: (a, b) => gitIsAncestor(cwd, a, b)
4141
4885
  });
4142
4886
  response.repositoryCheck = repositoryCheck(cwd, response.branchContext);
4887
+ response.localState = localStateSummary(cwd, flags);
4143
4888
 
4144
4889
  await appendVerificationPacket(api, cwd, session, accountId, flags, {
4145
4890
  type: 'component_status',
@@ -4159,6 +4904,8 @@ async function statusComponentsCommand(flags) {
4159
4904
 
4160
4905
  printSessionResolutionWarning(sessionContext);
4161
4906
  printResolvedBaseUrl(baseUrl);
4907
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace, dataMode: response.dataMode || dataMode });
4908
+ printLocalStateWarnings(cwd, flags);
4162
4909
  console.log('Data mode:', response.dataMode || dataMode);
4163
4910
  console.log('Mode:', response.mode || 'status');
4164
4911
  printStatusSummary(response, flags);
@@ -5812,6 +6559,17 @@ function short(sha) {
5812
6559
  return sha ? String(sha).slice(0, 8) : 'unknown';
5813
6560
  }
5814
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
+
5815
6573
  async function commitComponentsCommand(flags) {
5816
6574
  const cwd = process.cwd();
5817
6575
  ensureLocalState(cwd);
@@ -6363,7 +7121,12 @@ function printTestRunPivots(status = {}) {
6363
7121
  }
6364
7122
  tests.filter((test) => test && test.passed === false).forEach((test) => {
6365
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) : ''));
6366
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
+ }
6367
7130
  if (test.threadGroupingId || test.threadGroupId || test.traceId) {
6368
7131
  console.log(' Trace:', test.traceId || test.threadGroupingId || test.threadGroupId);
6369
7132
  }
@@ -6379,7 +7142,8 @@ function printTestRunPivots(status = {}) {
6379
7142
  (entry.signature ? ' ' + short(entry.signature) : '')).join(', '));
6380
7143
  }
6381
7144
  if (Array.isArray(test.liveHttpCalls) && test.liveHttpCalls.length) {
6382
- 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)' : ''));
6383
7147
  }
6384
7148
  });
6385
7149
  }
@@ -6391,6 +7155,249 @@ function formatDurationMs(value) {
6391
7155
  return (ms / 1000).toFixed(ms < 10000 ? 1 : 0) + 's';
6392
7156
  }
6393
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
+
6394
7401
  async function waitForToolStatus(api, cwd, options) {
6395
7402
  const started = Date.now();
6396
7403
  let pollDelayMs = parsePositiveInt(options.pollIntervalMs, 1000);
@@ -6422,6 +7429,19 @@ async function waitForToolStatus(api, cwd, options) {
6422
7429
  }
6423
7430
  }
6424
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
+
6425
7445
  function watchWebsocket(baseUrl, topicId, taskId, options = {}) {
6426
7446
  const emit = options.stderr ? console.error : console.log;
6427
7447
  const client = new Client({
@@ -6442,7 +7462,11 @@ function watchWebsocket(baseUrl, topicId, taskId, options = {}) {
6442
7462
  return;
6443
7463
  }
6444
7464
  if (payload.tests || payload.name || payload.total != null) {
6445
- 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));
6446
7470
  }
6447
7471
  }
6448
7472
  } catch (err) {
@@ -6525,12 +7549,20 @@ async function testCommand(flags) {
6525
7549
  withStdoutRoutedToStderr(jsonOutput, () => {
6526
7550
  printSessionResolutionWarning(sessionContext);
6527
7551
  printResolvedBaseUrl(baseUrl);
7552
+ printLocalCommandContext(cwd, flags, { accountId: asAccountId || accountId, branchName, workspace, dataMode });
7553
+ printLocalStateWarnings(cwd, flags);
6528
7554
  printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
6529
7555
  printStagingLaneOwnerNotice(start.staging || {});
6530
7556
  if (start.staging && Array.isArray(start.staging.accountLanes)) {
6531
7557
  printOrphanedWorkspaceWarning(start.staging);
6532
7558
  printAccountLanes({ accountLanes: start.staging.accountLanes, branchName, workspace });
6533
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));
6534
7566
  console.log('Test run started:', start.taskId);
6535
7567
  });
6536
7568
  runtimeState.currentTestTaskId = start.taskId;
@@ -6539,11 +7571,12 @@ async function testCommand(flags) {
6539
7571
  if (flags.watch !== 'false') {
6540
7572
  const topic = session.websocketTopic || start.websocketTopic || (session.user && String(session.user.uuid || '').replace(/-/g, ''));
6541
7573
  if (topic) {
6542
- stopWs = watchWebsocket(baseUrl, topic, start.taskId, { stderr: jsonOutput });
7574
+ stopWs = watchWebsocket(baseUrl, topic, start.taskId, { stderr: jsonOutput, verbose: flagEnabled(flags.verbose) });
6543
7575
  }
6544
7576
  }
6545
7577
 
6546
7578
  const status = await waitForStatus(api, cwd, accountId, branchName, start.taskId, session.token, dataMode);
7579
+ status.localState = localStateSummary(cwd, flags);
6547
7580
  if (stopWs) {
6548
7581
  stopWs();
6549
7582
  }
@@ -6556,6 +7589,7 @@ async function testCommand(flags) {
6556
7589
  } else {
6557
7590
  console.log('Final status:', JSON.stringify(status, null, 2));
6558
7591
  printStagingLaneOwnerNotice(status.staging || {});
7592
+ testRunSummaryLines(status).forEach((line) => console.log(line));
6559
7593
  printTestRunPivots(status);
6560
7594
  }
6561
7595
 
@@ -6625,10 +7659,14 @@ async function testCommand(flags) {
6625
7659
  unmatchedTestNames: unmatched
6626
7660
  },
6627
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,
6628
7666
  limitations: []
6629
7667
  .concat(unmatched.length ? ['One or more requested test case selectors matched no case.'] : [])
6630
7668
  .concat(nondeterminism ? [nondeterminism.message] : []),
6631
- rawRefs: { testStatusKey: start.taskId },
7669
+ rawRefs: { testStatusKey: start.taskId, durableTestRun: status.result && status.result.durableRecord ? start.taskId : undefined },
6632
7670
  status
6633
7671
  }, { quiet: jsonOutput });
6634
7672
  }
@@ -6665,6 +7703,11 @@ async function testStatusCommand(flags) {
6665
7703
  printResolvedBaseUrl(baseUrl);
6666
7704
  console.log('Test run status:', status.status || 'unknown');
6667
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');
6668
7711
  console.log('Data mode:', status.dataMode || dataMode);
6669
7712
  if (status.result) {
6670
7713
  console.log('Cases:', (status.result.passed || 0) + ' passed, ' + (status.result.failed || 0) + ' failed, ' + (status.result.total || 0) + ' total');
@@ -6672,12 +7715,100 @@ async function testStatusCommand(flags) {
6672
7715
  if (status.error || status.message) {
6673
7716
  console.log('Message:', status.error || status.message);
6674
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);
6675
7721
  if (status.status === 'failed' || (status.result && status.result.failed > 0)) {
6676
7722
  process.exitCode = 1;
6677
7723
  }
6678
7724
  return status;
6679
7725
  }
6680
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
+
6681
7812
  /*
6682
7813
  ## Tokens, Tools, Verification, And Config
6683
7814
  */
@@ -6747,7 +7878,8 @@ async function tokenCommand(flags) {
6747
7878
  accountLanes: data.accountLanes,
6748
7879
  dataMode: data.dataMode || dataMode,
6749
7880
  shortBasePath: data.shortBasePath,
6750
- embeddableUrl
7881
+ embeddableUrl,
7882
+ localState: localStateSummary(cwd, flags)
6751
7883
  };
6752
7884
 
6753
7885
  // What the token will RESOLVE AS. `accountId` says which account it executes as; on a hierarchy the
@@ -6770,7 +7902,11 @@ async function tokenCommand(flags) {
6770
7902
  output.embedSnippet = data.embedSnippet;
6771
7903
  }
6772
7904
 
6773
- 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
+ }
6774
7910
  console.log(JSON.stringify(output, null, 2));
6775
7911
 
6776
7912
  await appendVerificationPacket(api, cwd, session, accountId, flags, {
@@ -6786,7 +7922,7 @@ async function tokenCommand(flags) {
6786
7922
  },
6787
7923
  evidenceCategories: ['token_inspect'],
6788
7924
  token: Object.assign({}, output, { tokenKey: output.tokenKey ? '[redacted]' : undefined, embedTokenKey: output.embedTokenKey ? '[redacted]' : undefined })
6789
- });
7925
+ }, { quiet: flagEnabled(flags.json) });
6790
7926
  }
6791
7927
 
6792
7928
  async function tokenInspectCommand(flags) {
@@ -6988,6 +8124,7 @@ async function toolCommand(flags) {
6988
8124
 
6989
8125
  data.responseFile = statusResponse.responseFile;
6990
8126
  data.sessionLog = sessionJsonlFile(cwd);
8127
+ data.localState = localStateSummary(cwd, flags);
6991
8128
  // A completed run can still carry a tool-level refusal — see toolResultFailed.
6992
8129
  const polledFailed = data.toolSuccess === false || toolResultFailed(data.result);
6993
8130
  if (polledFailed) {
@@ -7010,6 +8147,8 @@ async function toolCommand(flags) {
7010
8147
  }
7011
8148
  printSessionResolutionWarning(sessionContext);
7012
8149
  printResolvedBaseUrl(baseUrl);
8150
+ printLocalCommandContext(cwd, flags, { accountId: statusAccountId, branchName: flags.branch || (stored && stored.branchName) || branchName, dataMode: data.dataMode || dataMode });
8151
+ printLocalStateWarnings(cwd, flags);
7013
8152
  console.log('Tool call status:', data.status);
7014
8153
  console.log('Call ID:', requestedCallId);
7015
8154
  console.log('Data mode:', data.dataMode || dataMode);
@@ -7064,6 +8203,7 @@ async function toolCommand(flags) {
7064
8203
  const toolFailureMessage = data.toolMessage || toolResultMessage(data.result);
7065
8204
  data.responseFile = response.responseFile;
7066
8205
  data.sessionLog = sessionJsonlFile(cwd);
8206
+ data.localState = localStateSummary(cwd, flags);
7067
8207
  if (toolFailureMessage && !data.toolMessage) data.toolMessage = toolFailureMessage;
7068
8208
 
7069
8209
  let evidenceData = data;
@@ -7072,6 +8212,8 @@ async function toolCommand(flags) {
7072
8212
  if (!jsonOutput) {
7073
8213
  printSessionResolutionWarning(sessionContext);
7074
8214
  printResolvedBaseUrl(baseUrl);
8215
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace: resolveWorkspace(cwd, flags), dataMode: data.dataMode || dataMode });
8216
+ printLocalStateWarnings(cwd, flags);
7075
8217
  console.log(asyncMode ? 'Tool call started.'
7076
8218
  : (toolFailed ? 'Tool call FAILED — the tool ran and returned an error.' : 'Tool call succeeded.'));
7077
8219
  if (toolFailed && toolFailureMessage) console.log('Tool error:', toolFailureMessage);
@@ -7140,6 +8282,202 @@ async function toolCommand(flags) {
7140
8282
  }
7141
8283
  }
7142
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
+
7143
8481
  async function verifyCommand(flags, subcommand) {
7144
8482
  const cwd = process.cwd();
7145
8483
  ensureLocalState(cwd);
@@ -7158,6 +8496,8 @@ async function verifyCommand(flags, subcommand) {
7158
8496
  const active = readActiveVerificationEnvelope(cwd, activeContext);
7159
8497
  printSessionResolutionWarning(sessionContext);
7160
8498
  printResolvedBaseUrl(baseUrl);
8499
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace, dataMode });
8500
+ printLocalStateWarnings(cwd, flags);
7161
8501
  console.log('Context:', 'account=' + accountId + ', branch=' + branchName + ', workspace=' + (workspace || 'shared') + ', dataMode=' + dataMode);
7162
8502
  if (!active) {
7163
8503
  const legacy = readActiveVerificationEnvelope(cwd);
@@ -7178,6 +8518,8 @@ async function verifyCommand(flags, subcommand) {
7178
8518
  if (!envelopeId) throw new Error('Usage: remits-cli verify use <envelopeId>');
7179
8519
  writeActiveVerificationEnvelope(cwd, envelopeId, activeContext);
7180
8520
  console.log('Active verification envelope:', envelopeId);
8521
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace, dataMode });
8522
+ printLocalStateWarnings(cwd, flags);
7181
8523
  console.log('Context:', 'account=' + accountId + ', branch=' + branchName + ', workspace=' + (workspace || 'shared') + ', dataMode=' + dataMode);
7182
8524
  return;
7183
8525
  }
@@ -7190,6 +8532,7 @@ async function verifyCommand(flags, subcommand) {
7190
8532
  } else {
7191
8533
  writeActiveVerificationEnvelope(cwd, null, activeContext);
7192
8534
  console.log('Verification envelope cleared for this context.');
8535
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace, dataMode });
7193
8536
  console.log('Context:', 'account=' + accountId + ', branch=' + branchName + ', workspace=' + (workspace || 'shared') + ', dataMode=' + dataMode);
7194
8537
  }
7195
8538
  return;
@@ -7241,6 +8584,8 @@ async function verifyCommand(flags, subcommand) {
7241
8584
  writeLocalVerificationEnvelope(cwd, envelope);
7242
8585
  writeActiveVerificationEnvelope(cwd, envelope.envelopeId, activeContext);
7243
8586
  console.log('Verification envelope started:', envelope.envelopeId);
8587
+ printLocalCommandContext(cwd, flags, { accountId, branchName, workspace, dataMode });
8588
+ printLocalStateWarnings(cwd, flags);
7244
8589
  console.log('Target:', 'account=' + accountId + ', branch=' + branchName + ', workspace=' + (workspace || 'shared') + ', dataMode=' + dataMode);
7245
8590
  printAcceptanceWarnings(envelope);
7246
8591
  printEnvelopeWarnings(envelope);
@@ -8104,7 +9449,10 @@ function detectIssueSignals(repoEntries, globalFiles) {
8104
9449
  }
8105
9450
 
8106
9451
  for (const repo of repoEntries || []) {
8107
- 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)');
8108
9456
  if (!sessionLogFile || !sessionLogFile.path || !fs.existsSync(sessionLogFile.path)) {
8109
9457
  continue;
8110
9458
  }
@@ -8300,23 +9648,41 @@ function buildGlobalFileSnapshot() {
8300
9648
  function buildRepoSnapshot(entry) {
8301
9649
  const directory = entry.directory;
8302
9650
  const localPaths = localStatePaths(directory);
9651
+ const actors = listActorDirectories(directory);
8303
9652
  const currentSessionName = fs.existsSync(localPaths.currentSessionFile)
8304
9653
  ? fs.readFileSync(localPaths.currentSessionFile, 'utf8').trim()
8305
9654
  : '';
8306
9655
  const currentSessionLog = currentSessionName
8307
9656
  ? path.join(localPaths.sessionsDir, currentSessionName + '.jsonl')
8308
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;
8309
9664
  const repoFiles = [
8310
9665
  { label: 'account-info.json', path: entry.accountInfoPath || path.join(directory, 'account-info.json'), mode: 'json' },
8311
9666
  { label: 'account-hierarchy.json', path: path.join(directory, 'account-hierarchy.json'), mode: 'json' },
8312
- { label: '.remits-cli/current-session.txt', path: localPaths.currentSessionFile, mode: 'text' },
8313
- { label: '.remits-cli/tools/tools.json', path: path.join(localPaths.toolsDir, 'tools.json'), mode: 'json' },
8314
- { 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' }
8315
9672
  ];
8316
9673
 
8317
9674
  return {
8318
9675
  ...entry,
8319
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
+ },
8320
9686
  files: repoFiles.map((file) => {
8321
9687
  const stat = file.path ? fileStat(file.path) : null;
8322
9688
  let content = null;
@@ -13048,6 +14414,8 @@ function printComponentsHelp(subcommand) {
13048
14414
  function printTestHelp() {
13049
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]');
13050
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');
13051
14419
  console.log('');
13052
14420
  console.log('Runs a Test component against the staged/variant world for this checkout.');
13053
14421
  console.log('Examples:');
@@ -13065,6 +14433,38 @@ function printTestHelp() {
13065
14433
  console.log(' value with --variant-branch none when existing staged entries would shadow DB rows.');
13066
14434
  console.log(' --json prints only the final status JSON to stdout; banners and progress go to stderr.');
13067
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"}]}]}');
13068
14468
  }
13069
14469
 
13070
14470
  function printToolHelp() {
@@ -13138,6 +14538,7 @@ async function main() {
13138
14538
  migrateSessionIfNeeded();
13139
14539
  const originalArgv = process.argv.slice(2);
13140
14540
  const args = parseArgs(originalArgv);
14541
+ configureLocalActor(args);
13141
14542
  const [command, subcommand] = args._;
13142
14543
  const wantsHelp = args.help === true || subcommand === 'help' || subcommand === '--help' || args._.includes('--help');
13143
14544
  const wantsJson = flagEnabled(args.json);
@@ -13168,7 +14569,7 @@ async function main() {
13168
14569
  try { updateAccountRepoIndex(process.cwd()); } catch (_) { /* not in account repo */ }
13169
14570
  // Auto-start the background service if not already running.
13170
14571
  // Skip for lifecycle subcommands and help.
13171
- 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';
13172
14573
  const isHelpCmd = !command || command === 'help' || command === '--help' || wantsHelp;
13173
14574
  if (!wantsJson && !isHelpCmd && !isLifecycleCmd && !isListenerRunning()) {
13174
14575
  try {
@@ -13191,7 +14592,7 @@ async function main() {
13191
14592
 
13192
14593
  if (!command || command === 'help' || command === '--help') {
13193
14594
  console.log('Usage: remits-cli <command>');
13194
- console.log('Global options: --no-auto-update');
14595
+ console.log('Global options: --no-auto-update, --local-agent NAME');
13195
14596
  console.log(' remits-cli auth [--base-url URL] [--account-id ID] [--port 8765] [--data-mode test|prod]');
13196
14597
  console.log(' remits-cli sessions [list|remove] [--account-id ID] [--base-url URL] [--data-mode test|prod]');
13197
14598
  console.log(' remits-cli config [set] [--agent claude|codex|gemini]');
@@ -13204,6 +14605,7 @@ async function main() {
13204
14605
  console.log(' remits-cli start [--foreground true] [--port 8787] # human dashboard in the browser');
13205
14606
  console.log(' remits-cli stop');
13206
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');
13207
14609
  console.log(' remits-cli whoami [--base-url URL] [--account-id ID] [--data-mode test|prod] [--json]');
13208
14610
  console.log(' remits-cli listen [stop|status] [--foreground true] # compatibility alias');
13209
14611
  console.log(' remits-cli data-mode [set test|prod]');
@@ -13228,6 +14630,10 @@ async function main() {
13228
14630
  console.log(' remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk');
13229
14631
  console.log(' remits-cli components branch <name> --retire [--force] # delete the branch\'s overlays');
13230
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');
13231
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]');
13232
14638
  console.log(' remits-cli token inspect --token <token|tokenKey|URL> [--base-url URL] [--account-id ID]');
13233
14639
  console.log('');
@@ -13398,6 +14804,11 @@ async function main() {
13398
14804
  return;
13399
14805
  }
13400
14806
 
14807
+ if (command === 'doctor') {
14808
+ await doctorCommand(args, subcommand);
14809
+ return;
14810
+ }
14811
+
13401
14812
  if (command === 'whoami') {
13402
14813
  await whoamiCommand(args);
13403
14814
  return;
@@ -13444,6 +14855,23 @@ async function main() {
13444
14855
  await testStatusCommand(args);
13445
14856
  return;
13446
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
+ }
13447
14875
 
13448
14876
  if (command === 'token') {
13449
14877
  await tokenCommand(args);