@remits/remits-cli 0.1.115 → 0.1.117

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
@@ -544,6 +544,8 @@ function localStatePaths(cwd) {
544
544
  toolsDir: path.join(base, 'tools'),
545
545
  sessionsDir: path.join(base, 'sessions'),
546
546
  toolResponsesDir: path.join(base, 'tool-responses'),
547
+ verificationDir: path.join(base, 'verification'),
548
+ activeVerificationFile: path.join(base, 'verification', 'active'),
547
549
  currentSessionFile: path.join(base, 'current-session.txt'),
548
550
  workspaceFile: path.join(base, 'workspace')
549
551
  };
@@ -649,6 +651,7 @@ function ensureLocalState(cwd) {
649
651
  ensureDir(paths.toolsDir);
650
652
  ensureDir(paths.sessionsDir);
651
653
  ensureDir(paths.toolResponsesDir);
654
+ ensureDir(paths.verificationDir);
652
655
  return paths;
653
656
  }
654
657
 
@@ -755,6 +758,196 @@ function readToolResponseFile(cwd, callId) {
755
758
  }
756
759
  }
757
760
 
761
+ function verificationPaths(cwd, envelopeId) {
762
+ const paths = ensureLocalState(cwd);
763
+ const base = envelopeId ? path.join(paths.verificationDir, String(envelopeId)) : paths.verificationDir;
764
+ return {
765
+ root: paths.verificationDir,
766
+ activeFile: paths.activeVerificationFile,
767
+ base,
768
+ envelopeFile: path.join(base, 'envelope.json'),
769
+ manifestFile: path.join(base, 'manifest.json'),
770
+ packetsDir: path.join(base, 'packets')
771
+ };
772
+ }
773
+
774
+ function readActiveVerificationEnvelope(cwd) {
775
+ const file = verificationPaths(cwd).activeFile;
776
+ try {
777
+ if (!fs.existsSync(file)) return null;
778
+ const id = fs.readFileSync(file, 'utf8').trim();
779
+ return id || null;
780
+ } catch (_) {
781
+ return null;
782
+ }
783
+ }
784
+
785
+ function writeActiveVerificationEnvelope(cwd, envelopeId) {
786
+ const paths = verificationPaths(cwd);
787
+ if (!envelopeId) {
788
+ try { if (fs.existsSync(paths.activeFile)) fs.unlinkSync(paths.activeFile); } catch (_) { /* best-effort */ }
789
+ return null;
790
+ }
791
+ atomicWriteFile(paths.activeFile, String(envelopeId) + '\n');
792
+ return String(envelopeId);
793
+ }
794
+
795
+ function verificationEnvelopeIdForCommand(cwd, flags = {}) {
796
+ if (flagEnabled(flags['no-verify-envelope']) || flagEnabled(flags.noVerifyEnvelope)) return null;
797
+ const explicit = flags['verify-envelope'] || flags.verifyEnvelope || flags.envelope;
798
+ if (explicit !== undefined && explicit !== null && explicit !== true) return String(explicit).trim() || null;
799
+ return readActiveVerificationEnvelope(cwd);
800
+ }
801
+
802
+ function writeLocalVerificationEnvelope(cwd, envelope) {
803
+ if (!envelope || !envelope.envelopeId) return null;
804
+ const paths = verificationPaths(cwd, envelope.envelopeId);
805
+ ensureDir(paths.base);
806
+ ensureDir(paths.packetsDir);
807
+ atomicWriteFile(paths.envelopeFile, JSON.stringify(envelope, null, 2));
808
+ if (envelope.acceptance) {
809
+ atomicWriteFile(paths.manifestFile, JSON.stringify(envelope.acceptance, null, 2));
810
+ }
811
+ (envelope.packets || []).forEach((packet) => writeLocalVerificationPacket(cwd, envelope.envelopeId, packet));
812
+ return paths.envelopeFile;
813
+ }
814
+
815
+ function readLocalVerificationEnvelope(cwd, envelopeId) {
816
+ const paths = verificationPaths(cwd, envelopeId);
817
+ if (!fs.existsSync(paths.envelopeFile)) return null;
818
+ try {
819
+ return JSON.parse(fs.readFileSync(paths.envelopeFile, 'utf8'));
820
+ } catch (_) {
821
+ return null;
822
+ }
823
+ }
824
+
825
+ function writeLocalVerificationPacket(cwd, envelopeId, packet) {
826
+ if (!envelopeId || !packet) return null;
827
+ const paths = verificationPaths(cwd, envelopeId);
828
+ ensureDir(paths.packetsDir);
829
+ const packetId = packet.packetId || ('local-' + crypto.randomUUID());
830
+ const file = path.join(paths.packetsDir, packetId + '.json');
831
+ atomicWriteFile(file, JSON.stringify(Object.assign({}, packet, { packetId }), null, 2));
832
+ return file;
833
+ }
834
+
835
+ function parseManifestFile(file) {
836
+ const resolved = path.resolve(process.cwd(), String(file));
837
+ const text = fs.readFileSync(resolved, 'utf8');
838
+ if (resolved.endsWith('.yml') || resolved.endsWith('.yaml')) {
839
+ return YAML.parse(text);
840
+ }
841
+ return JSON.parse(text);
842
+ }
843
+
844
+ function sha256File(file) {
845
+ const resolved = path.resolve(process.cwd(), String(file));
846
+ const hash = crypto.createHash('sha256');
847
+ hash.update(fs.readFileSync(resolved));
848
+ const stat = fs.statSync(resolved);
849
+ return { path: resolved, sha256: hash.digest('hex'), size: stat.size };
850
+ }
851
+
852
+ function safeGitValue(cwd, command) {
853
+ try {
854
+ return runGit(cwd, command);
855
+ } catch (_) {
856
+ return null;
857
+ }
858
+ }
859
+
860
+ function collectVerificationSource(cwd, flags = {}) {
861
+ const branchName = flags.branch || currentBranch(cwd);
862
+ return {
863
+ workingDirectory: cwd,
864
+ gitBranch: branchName,
865
+ gitHead: safeGitValue(cwd, 'git rev-parse HEAD'),
866
+ originHead: safeGitValue(cwd, 'git rev-parse ' + shellQuote('origin/' + branchName)),
867
+ mergeBase: safeGitValue(cwd, 'git merge-base HEAD ' + shellQuote('origin/' + branchName)),
868
+ workspace: resolveWorkspace(cwd, flags),
869
+ workspaceSource: workspaceSource(cwd, flags),
870
+ stagingLane: branchName + ':' + (resolveWorkspace(cwd, flags) || 'shared'),
871
+ sourceLayer: 'unknown'
872
+ };
873
+ }
874
+
875
+ function collectVerificationAccount(cwd, accountId, baseUrl, dataMode) {
876
+ const info = loadAccountInfo(cwd) || {};
877
+ const resolution = info.resolution || info.repoContext || {};
878
+ return {
879
+ repoAccountId: Number(accountId || accountIdFromAccountInfo(info)) || null,
880
+ executionAccountId: resolution.executionAccountId || resolution.accountId || accountId || null,
881
+ addressedAccountId: resolution.addressedAccountId || resolution.accountId || accountId || null,
882
+ componentOwnerAccountId: resolution.componentOwnerAccountId || resolution.ownerAccountId || null,
883
+ resolvedDatabaseName: resolution.resolvedDatabaseName || resolution.databaseName || null,
884
+ host: resolution.resolvedDomainName || resolution.host || normalizeBaseUrl(baseUrl),
885
+ dataMode,
886
+ dataModeSource: 'cli',
887
+ accountRole: resolution.role || null,
888
+ accountType: resolution.type || null,
889
+ testAccount: resolution.testAccount === true || info.testAccount === true
890
+ };
891
+ }
892
+
893
+ async function postVerificationCommand(api, cwd, session, accountId, command, payload) {
894
+ const response = await loggedPost(api, cwd, '/cli/verification', Object.assign({
895
+ token: session.token,
896
+ accountId,
897
+ command
898
+ }, payload || {})).then((r) => r.data);
899
+ if (!response.success) {
900
+ throw new Error(response.message || 'Verification command failed');
901
+ }
902
+ return response;
903
+ }
904
+
905
+ async function appendVerificationPacket(api, cwd, session, accountId, flags, packet, options = {}) {
906
+ const envelopeId = verificationEnvelopeIdForCommand(cwd, flags);
907
+ if (!envelopeId) return null;
908
+ const finalPacket = Object.assign({
909
+ packetId: crypto.randomUUID(),
910
+ createdAt: new Date().toISOString(),
911
+ success: true
912
+ }, packet || {});
913
+ finalPacket.envelopeId = envelopeId;
914
+ writeLocalVerificationPacket(cwd, envelopeId, finalPacket);
915
+ try {
916
+ const response = await postVerificationCommand(api, cwd, session, accountId, 'packet', {
917
+ envelopeId,
918
+ packet: finalPacket
919
+ });
920
+ if (response.packet) writeLocalVerificationPacket(cwd, envelopeId, response.packet);
921
+ if (response.envelope) writeLocalVerificationEnvelope(cwd, response.envelope);
922
+ if (!options.quiet) console.log('Verification envelope:', envelopeId, '(packet ' + finalPacket.type + ' attached)');
923
+ return response;
924
+ } catch (err) {
925
+ if (!options.quiet) {
926
+ console.error('Verification evidence captured locally but not attached to the platform envelope:', describeError(err));
927
+ }
928
+ return { localOnly: true, envelopeId, packet: finalPacket };
929
+ }
930
+ }
931
+
932
+ function buildCommandWorld(response, fallback = {}) {
933
+ const source = (response && response.staging) || {};
934
+ const resolution = (response && response.resolution) || {};
935
+ return {
936
+ accountId: response && (response.accountId || response.ownerAccountId) || fallback.accountId,
937
+ executionAccountId: response && (response.executionAccountId || response.accountId) || resolution.executionAccountId || fallback.executionAccountId,
938
+ addressedAccountId: resolution.addressedAccountId || fallback.addressedAccountId,
939
+ host: fallback.host,
940
+ dataMode: response && response.dataMode || fallback.dataMode,
941
+ dataModeSource: response && response.dataModeSource || fallback.dataModeSource,
942
+ branchName: response && response.branchName || fallback.branchName,
943
+ workspace: response && response.workspace !== undefined ? response.workspace : fallback.workspace,
944
+ stagingLane: response && response.stagingLane || source.stagingLane || fallback.stagingLane,
945
+ componentBranch: resolution.componentBranch || response && response.variantBranch || fallback.componentBranch,
946
+ variantId: resolution.variantId || fallback.variantId,
947
+ sourceLayer: fallback.sourceLayer || (source.testComponentSource === 'staged' ? 'staged' : undefined)
948
+ };
949
+ }
950
+
758
951
  function normalizeName(name) {
759
952
  return name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/\s+/g, ' ').trim();
760
953
  }
@@ -2458,12 +2651,29 @@ async function pushComponentsCommand(flags) {
2458
2651
  const mode = requestedMode === 'push' ? 'stage' : requestedMode;
2459
2652
  const changedFromWorkingTree = changedComponentsFromWorkingTree(cwd);
2460
2653
 
2461
- // --changed-only stages just the components this working tree edited, instead of re-uploading all of
2462
- // them. Two reasons it matters here: it is what makes an iteration loop cheap on a large repo, and it
2463
- // deliberately does NOT send `replace`, so the server's reconcile pass cannot delete the entries for
2464
- // components this partial stage did not mention.
2465
- const changedOnly = flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly);
2654
+ // WHAT this stage is. Three shapes, and the difference between them is the difference between a lane
2655
+ // that reads as "7 components in flight" and one that reads as "115 staged":
2656
+ //
2657
+ // full snapshot the whole repo manifest, lane reconciled to it `components stage`
2658
+ // workset replace only the git-changed components, lane reconciled `components stage --workset`
2659
+ // workset merge only the git-changed components, merged into the lane `components stage --changed-only`
2660
+ //
2661
+ // `--changed-only` keeps its old merge semantics deliberately — changing what an existing flag does
2662
+ // silently is how a safe command becomes a destructive one for somebody's script. The exact-lane
2663
+ // behavior is the new flag.
2664
+ const worksetReplace = flagEnabled(flags.workset) ||
2665
+ ((flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly)) &&
2666
+ (flagEnabled(flags['replace-lane']) || flagEnabled(flags.replaceLane)));
2667
+ const changedOnly = worksetReplace || flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly);
2668
+ const stageMode = worksetReplace ? 'workset-replace' : (changedOnly ? 'workset-merge' : 'full-snapshot');
2669
+ const emptyWorksetPolicy = normalizeEmptyWorksetPolicy(flags);
2670
+
2466
2671
  let components = collectComponents(cwd);
2672
+ // Changes git reported that a component payload cannot carry — a deleted component file has nothing to
2673
+ // stage, and Redis staging has no way to say "hide this during CLI-scoped runs". Removing its staged
2674
+ // entry falls back to the committed row, so the component still resolves. Reported out loud rather than
2675
+ // dropped, because "my delete did not take effect" otherwise looks like a platform bug.
2676
+ const unstageable = unrepresentableChanges(changedFromWorkingTree, components);
2467
2677
  if (changedOnly) {
2468
2678
  if (changedFromWorkingTree === null) {
2469
2679
  throw new Error('--changed-only needs a git working tree to establish the changed set, and this directory is not one.');
@@ -2472,10 +2682,20 @@ async function pushComponentsCommand(flags) {
2472
2682
  entry.type + ':' + (entry.id ? 'id:' + entry.id : 'name:' + String(entry.name || '').toLowerCase())));
2473
2683
  components = components.filter((component) => wanted.has(
2474
2684
  component.type + ':' + (component.id ? 'id:' + component.id : 'name:' + String(component.name || '').toLowerCase())));
2475
- if (!components.length) {
2476
- console.log('--changed-only: this working tree has no uncommitted component edits; nothing to stage.');
2685
+ if (!components.length && !(worksetReplace && emptyWorksetPolicy === 'clear')) {
2686
+ // Deliberately NOT an error, and deliberately not a clear. An agent that has not edited anything yet
2687
+ // is in an ordinary state; failing its loop teaches it nothing, and reconciling the lane to an empty
2688
+ // manifest would delete the overlay its next test run depends on.
2689
+ console.log('Stage mode:', stageMode);
2690
+ console.log('Workset detected from git: 0 component(s) — nothing was staged and the lane was left as it is.');
2691
+ if (worksetReplace) {
2692
+ console.log(' The lane was NOT cleared. Clearing stays explicit: `remits-cli components clear --all`,');
2693
+ console.log(' or re-run with `--empty-workset clear` if an empty lane is what you meant.');
2694
+ }
2477
2695
  printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
2478
- return { success: true, mode, updated: 0, unchanged: 0, skipped: [], changedOnly: true };
2696
+ printUnrepresentableChanges(unstageable);
2697
+ return { success: true, mode, updated: 0, unchanged: 0, skipped: [], changedOnly: true, stageMode,
2698
+ worksetCount: 0, staged: false };
2479
2699
  }
2480
2700
  }
2481
2701
 
@@ -2503,13 +2723,45 @@ async function pushComponentsCommand(flags) {
2503
2723
  ...runContextPayload(flags),
2504
2724
  agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined,
2505
2725
  acknowledge: flags.acknowledge || undefined,
2506
- // A partial (--changed-only) stage must not trigger the server's reconcile pass: reconcile deletes
2507
- // every staged entry absent from the manifest, which for a partial manifest is nearly all of them.
2726
+ // What this stage IS, stated rather than implied. The server derives the lane behavior from the mode
2727
+ // (CliComponentStaging.stageRequest) instead of from `replace`, which was overloaded: a partial
2728
+ // manifest with `replace: true` would make the reconcile pass delete nearly the whole lane, so
2729
+ // `--changed-only` had to send `replace: false` and could therefore never shrink a lane it inherited.
2730
+ stageMode,
2731
+ manifestScope: changedOnly ? 'working-tree' : 'repo',
2732
+ emptyWorksetPolicy,
2733
+ // Whether git could answer at all. A zero rendered for an unavailable answer is exactly the kind of
2734
+ // confident wrong number this whole surface exists to remove.
2735
+ worksetKnown: changedFromWorkingTree !== null,
2736
+ // The git evidence, per component: which files changed and with what status. It is what lets the
2737
+ // console split a lane's "active workset" from the entries a previous full snapshot left behind.
2738
+ changedSet: (changedFromWorkingTree || []).concat(unstageable),
2739
+ // Kept for a platform that predates stageMode. Same meaning it always had.
2508
2740
  replace: !changedOnly,
2509
2741
  components
2510
2742
  });
2511
2743
  response.changedFromWorkingTree = changedFromWorkingTree || [];
2512
2744
  response.changedFromWorkingTreeAvailable = changedFromWorkingTree !== null;
2745
+ response.requestedStageMode = stageMode;
2746
+ response.unrepresentableChanges = unstageable;
2747
+
2748
+ await appendVerificationPacket(api, cwd, session, accountId, flags, {
2749
+ type: 'stage',
2750
+ success: response.success !== false,
2751
+ claim: 'Components staged for verification',
2752
+ world: buildCommandWorld(response, { accountId, dataMode, branchName, workspace, host: normalizeBaseUrl(baseUrl), sourceLayer: 'staged' }),
2753
+ revision: collectVerificationSource(cwd, flags),
2754
+ assertions: [
2755
+ { category: 'stage', stageMode },
2756
+ { category: 'staging_lane', value: response.stagingLane }
2757
+ ],
2758
+ evidenceCategories: ['stage', 'staging_lane'],
2759
+ rawRefs: { command: 'components stage' },
2760
+ stage: response.stage,
2761
+ laneSummary: response.laneSummary,
2762
+ changedFromWorkingTree: response.changedFromWorkingTree,
2763
+ unrepresentableChanges: response.unrepresentableChanges
2764
+ }, { quiet: flagEnabled(flags.json) });
2513
2765
 
2514
2766
  if (flagEnabled(flags.json)) {
2515
2767
  console.log(JSON.stringify(response, null, 2));
@@ -2520,11 +2772,53 @@ async function pushComponentsCommand(flags) {
2520
2772
  printResolvedBaseUrl(baseUrl);
2521
2773
  printStagingLane(response.branchName || branchName, response.workspace || workspace, workspaceSource(cwd, flags));
2522
2774
  console.log('Data mode:', response.dataMode || dataMode);
2523
- console.log('Mode:', response.mode || mode + (changedOnly ? ' (--changed-only)' : ''));
2524
2775
  printComponentPolicy(response);
2525
2776
  printStageSummary(response, flags);
2526
2777
  }
2527
2778
 
2779
+ /** `--empty-workset refuse|clear`. Refusing is the default, and the only safe one. */
2780
+ function normalizeEmptyWorksetPolicy(flags) {
2781
+ const raw = flags['empty-workset'] != null ? flags['empty-workset'] : flags.emptyWorkset;
2782
+ const value = raw === true ? '' : String(raw == null ? '' : raw).trim().toLowerCase();
2783
+ if (value === 'clear' || value === 'clears-lane') return 'clear';
2784
+ return 'refuse';
2785
+ }
2786
+
2787
+ /**
2788
+ * Git-reported component changes that a stage payload cannot carry.
2789
+ *
2790
+ * A deleted component file has no content to send, and Redis staging has no way to express "hide this
2791
+ * during CLI-scoped runs" — clearing its staged entry falls back to the committed row, so the component
2792
+ * still resolves. An agent that deletes a file, stages, and sees the component still running would read
2793
+ * that as a platform defect. Durable deletion lives in variant sync (tombstones), not here.
2794
+ *
2795
+ * Returns [] when git could not answer, because "nothing is unrepresentable" would be a claim.
2796
+ */
2797
+ function unrepresentableChanges(changedFromWorkingTree, collected) {
2798
+ if (!Array.isArray(changedFromWorkingTree)) return [];
2799
+ const present = new Set((collected || []).map((component) =>
2800
+ component.type + ':' + (component.id ? 'id:' + component.id : 'name:' + String(component.name || '').toLowerCase())));
2801
+ return changedFromWorkingTree
2802
+ .filter((entry) => !present.has(entry.type + ':' + (entry.id ? 'id:' + entry.id : 'name:' + String(entry.name || '').toLowerCase())))
2803
+ .map((entry) => Object.assign({}, entry, {
2804
+ unrepresentable: true,
2805
+ reason: (entry.statuses || []).some((s) => String(s).includes('D')) ? 'deleted' : 'no-local-files'
2806
+ }));
2807
+ }
2808
+
2809
+ function printUnrepresentableChanges(unstageable) {
2810
+ if (!unstageable || !unstageable.length) return;
2811
+ console.log('');
2812
+ console.log('NOT REPRESENTABLE IN REDIS STAGING — ' + unstageable.length + ' change(s):');
2813
+ unstageable.slice(0, 20).forEach((entry) => {
2814
+ console.log(' ' + entry.type + ' ' + (entry.id || entry.name || '(unknown)') +
2815
+ ' (' + entry.reason + ') ' + (entry.paths || []).join(', '));
2816
+ });
2817
+ console.log(' A staged entry cannot hide a component. Clearing it falls back to the committed row, so');
2818
+ console.log(' the component still resolves in a CLI-scoped run. Prove a deletion through the durable');
2819
+ console.log(' plan instead: remits-cli components sync --dry-run --summary --fail-on-errors');
2820
+ }
2821
+
2528
2822
  /**
2529
2823
  * A policy refusal is an ANSWER, not a transport failure.
2530
2824
  *
@@ -2602,15 +2896,55 @@ function printComponentCommandResponse(label, response, flags) {
2602
2896
  printFullResponseHint();
2603
2897
  }
2604
2898
 
2899
+ /**
2900
+ * What this stage did, in the terms a human is actually asking about.
2901
+ *
2902
+ * Three numbers, and they are three different questions:
2903
+ *
2904
+ * WORKSET how many components this working tree changed, per git
2905
+ * SUBMITTED how many this command uploaded
2906
+ * OVERLAY how many staged entries the lane now holds, which is what a run resolves
2907
+ *
2908
+ * They used to be reported as one number — the overlay — so an agent that edited 5 components in a repo
2909
+ * of 115 produced "Updated: 115" and a console badge reading "STAGED 115". Nobody could tell 115 edits
2910
+ * from one full snapshot, and those want opposite actions.
2911
+ */
2605
2912
  function printStageSummary(response, flags) {
2606
- // `stage` always uploads EVERY component in the repo, so the server's Updated/Unchanged counts
2607
- // describe the whole staging overlay, not what this checkout edited. That is what made "Updated: 75"
2608
- // read as "I changed 75 components". Lead with the working-tree delta, which is the number an agent
2609
- // is actually asking about, and label the server counts as the overlay they describe.
2610
2913
  const changed = Array.isArray(response.changedFromWorkingTree) ? response.changedFromWorkingTree : [];
2611
2914
  const tracked = response.changedFromWorkingTreeAvailable !== false;
2915
+ const stage = response.stage || {};
2916
+ const lane = response.laneSummary || {};
2917
+ // What we ASKED for, and what the platform says it did. Kept apart deliberately: comparing the server's
2918
+ // answer against a value that already fell back to it can never disagree, so the mismatch note below
2919
+ // could never fire.
2920
+ const requestedMode = response.requestedStageMode || null;
2921
+ const stageMode = stage.mode || requestedMode || 'full-snapshot';
2922
+
2923
+ console.log('Stage mode:', describeStageMode(stageMode));
2924
+
2925
+ // A platform that predates stage modes ignores `stageMode` and reads `replace`, which a workset stage
2926
+ // sends as false — so it MERGES where the caller asked for a lane replacement, and returns no `stage`
2927
+ // block to say so. Silence there is the worst outcome available: the agent believes the lane holds
2928
+ // exactly its workset while the platform left an inherited overlay in place. Detect it by what came
2929
+ // back rather than by a version number.
2930
+ if (stage.mode == null && response.staging) {
2931
+ console.log('NOTE: this platform predates stage modes. It read the legacy `replace` flag, so a');
2932
+ console.log(' --workset stage MERGED into the lane instead of reconciling it, and the workset/');
2933
+ console.log(' overlay split below is not available. Use `components clear --all` first if you need');
2934
+ console.log(' the lane to hold exactly your workset.');
2935
+ } else if (stage.mode && requestedMode && stage.mode !== requestedMode) {
2936
+ console.log('NOTE: asked the platform for `' + requestedMode + '`; it applied `' + stage.mode + '`.');
2937
+ }
2938
+
2939
+ if (response.sharedLane === true) {
2940
+ console.log('Lane: SHARED (no workspace) — a full stage REPLACES it rather than merging, so a second');
2941
+ console.log(' agent staging here overwrites what you are testing. `remits-cli workspace use --auto`.');
2942
+ }
2943
+
2944
+ // "git could not answer" and "git says nothing changed" are different facts, and only one of them is a
2945
+ // number. Never render the missing answer as zero.
2612
2946
  if (tracked) {
2613
- console.log('Components edited in this working tree:', changed.length);
2947
+ console.log('Workset detected from git:', changed.length, 'component(s)');
2614
2948
  changed.slice(0, 20).forEach((entry) => {
2615
2949
  const label = (entry.type || 'component') + ' ' + (entry.id || entry.name || '(unknown)');
2616
2950
  const name = entry.name && entry.id ? ' ' + entry.name : '';
@@ -2623,23 +2957,66 @@ function printStageSummary(response, flags) {
2623
2957
  if (!changed.length) {
2624
2958
  console.log(' (no uncommitted component edits — already-committed edits are staged but not listed here)');
2625
2959
  }
2960
+ } else {
2961
+ console.log('Workset detected from git: UNKNOWN (this is not a git working tree)');
2962
+ }
2963
+
2964
+ const submitted = stage.submittedCount != null
2965
+ ? stage.submittedCount
2966
+ : ((response.updated || 0) + (response.unchanged || 0));
2967
+ console.log('Submitted to the platform:', submitted, 'component(s) (' +
2968
+ (response.updated || 0) + ' newly written, ' + (response.unchanged || 0) + ' already current)');
2969
+ if (Array.isArray(response.skipped) && response.skipped.length) {
2970
+ console.log('Skipped (no type/hash/identity):', response.skipped.length);
2626
2971
  }
2627
- console.log('Staging overlay — newly written:', response.updated || 0);
2628
- console.log('Staging overlay — already current:', response.unchanged || 0);
2629
- console.log('Skipped:', Array.isArray(response.skipped) ? response.skipped.length : 0);
2630
- if (response.reconcile) {
2631
- console.log('Reconciled stale keys:', response.reconcile.removedCount || 0);
2972
+ if (stage.removedRetainedCount) {
2973
+ console.log('Removed from the lane by this stage:', stage.removedRetainedCount, 'staged entry(ies)');
2974
+ } else if (response.reconcile) {
2975
+ console.log('Removed from the lane by this stage:', response.reconcile.removedCount || 0, 'staged entry(ies)');
2632
2976
  }
2633
- if (response.staging) {
2634
- console.log('Total staged entries for this branch/user:', response.staging.remainingCount || 0);
2977
+
2978
+ const overlay = lane.stagedCount != null
2979
+ ? lane.stagedCount
2980
+ : (response.staging ? response.staging.remainingCount : null);
2981
+ if (overlay != null) {
2982
+ console.log('Materialized overlay now held by the lane:', overlay, 'component(s)');
2635
2983
  }
2984
+
2985
+ // The trap `--changed-only` leaves behind: it merges, so a lane inherited from an earlier full snapshot
2986
+ // keeps every one of those entries resolving ahead of committed source. Silence here is what made
2987
+ // "I staged 7 components" and "this lane holds 115" look like the same statement.
2988
+ const retained = lane.retainedCount;
2989
+ if (retained > 0) {
2990
+ console.log('');
2991
+ console.log('WARNING: ' + retained + ' staged component(s) in this lane were NOT submitted by this stage.');
2992
+ console.log(' They came from an earlier stage and still shadow committed source for every run in this lane.');
2993
+ console.log(' Make the lane exactly your workset: remits-cli components stage --workset');
2994
+ console.log(' Or drop the overlay entirely: remits-cli components clear --all');
2995
+ }
2996
+
2997
+ printUnrepresentableChanges(response.unrepresentableChanges || (stage.unrepresentable || []));
2636
2998
  printComponentCommandResponse('Components stage', response, flags);
2637
2999
  }
2638
3000
 
3001
+ function describeStageMode(mode) {
3002
+ switch (mode) {
3003
+ case 'full-snapshot':
3004
+ return 'full snapshot (whole repo manifest; the lane is reconciled to it)';
3005
+ case 'workset-replace':
3006
+ return 'workset replace (only the components git reports changed; the lane is reconciled to exactly them)';
3007
+ case 'workset-merge':
3008
+ return 'workset merge (only the components git reports changed, MERGED into whatever the lane already held)';
3009
+ case 'partial':
3010
+ return 'partial (single-component field edit)';
3011
+ default:
3012
+ return mode || 'unknown';
3013
+ }
3014
+ }
3015
+
2639
3016
  function printStatusSummary(response, flags) {
2640
3017
  printBranchContext(response);
2641
3018
  printStagingLane(response.branchName, response.workspace, null);
2642
- console.log('Staged count:', response.stagedCount || 0);
3019
+ printLaneSummary(response);
2643
3020
  printComponentTypeCounts(response.entries || []);
2644
3021
 
2645
3022
  // Every lane staged on this branch, not just this one. An agent that believes it is working alone can
@@ -2663,6 +3040,48 @@ function printStatusSummary(response, flags) {
2663
3040
  printComponentCommandResponse('Components staging', response, flags);
2664
3041
  }
2665
3042
 
3043
+ /**
3044
+ * What THIS lane holds, separated into the work in flight and the overlay carried along with it.
3045
+ *
3046
+ * Read authoritatively from the staging keys (not the lane index), so the numbers here are the ones a run
3047
+ * in this lane will actually resolve.
3048
+ */
3049
+ function printLaneSummary(response) {
3050
+ const lane = response.laneSummary || {};
3051
+ const overlay = lane.stagedCount != null ? lane.stagedCount : (response.stagedCount || 0);
3052
+
3053
+ console.log('Materialized overlay (what a run in this lane resolves):', overlay, 'component(s)');
3054
+ // An EMPTY lane has no last stage, so "Last stage: unknown" would invite a reader to go looking for a
3055
+ // stage that never happened. Nothing staged is clean state, not a missing answer.
3056
+ if (!overlay) {
3057
+ console.log('Nothing is staged in this lane; runs here resolve committed source.');
3058
+ } else if (lane.stageMode && lane.stageMode !== 'unknown') {
3059
+ console.log('Last stage:', describeStageMode(lane.stageMode));
3060
+ if (lane.submittedCount != null) {
3061
+ console.log(' submitted by that stage:', lane.submittedCount, 'component(s)');
3062
+ }
3063
+ // null is "not established", never zero. A lane staged by a build that predates stage provenance has
3064
+ // an unknown workset, and printing 0 would assert that nobody is working in it.
3065
+ if (lane.worksetKnown && lane.worksetCount != null) {
3066
+ console.log(' of which git reported as edited:', lane.worksetCount, 'component(s)');
3067
+ } else {
3068
+ console.log(' of which git reported as edited: unknown (that stage reported no git evidence — an');
3069
+ console.log(' older CLI, a non-git checkout, or a single-field MCP edit)');
3070
+ }
3071
+ if (lane.retainedCount > 0) {
3072
+ console.log(' retained from EARLIER stages:', lane.retainedCount, 'component(s) — these still shadow');
3073
+ console.log(' committed source. `components stage --workset` makes the lane exactly your workset.');
3074
+ }
3075
+ } else {
3076
+ console.log('Last stage: unknown (staged before stage modes existed, or by another door)');
3077
+ }
3078
+
3079
+ if (response.sharedLane === true) {
3080
+ console.log('Lane isolation: SHARED — no workspace. A full stage here REPLACES the lane rather than');
3081
+ console.log(' merging, so a second agent overwrites what you are testing. `remits-cli workspace use --auto`.');
3082
+ }
3083
+ }
3084
+
2666
3085
  // Every staging lane on the ACCOUNT, across branches.
2667
3086
  //
2668
3087
  // The block above only ever covered lanes on the SAME git branch, because that is all its key pattern
@@ -2689,8 +3108,14 @@ function printAccountLanes(response) {
2689
3108
  const who = lane.userEmail ? ' ' + lane.userEmail : '';
2690
3109
  const ttl = lane.expiresInSeconds != null
2691
3110
  ? ' expires in ~' + Math.round(lane.expiresInSeconds / 60) + 'm' : '';
3111
+ // Overlay first (it is what a run resolves), then the work the last stage actually submitted. A lane
3112
+ // showing "115 overlay / 7 workset" is one agent iterating on 7 components, not 115 edits in flight.
3113
+ const workset = (lane.worksetKnown && lane.worksetCountAsOf != null)
3114
+ ? ', workset ' + lane.worksetCountAsOf : '';
3115
+ const stageMode = lane.stageMode && lane.stageMode !== 'unknown' ? ' [' + lane.stageMode + ']' : '';
3116
+ const shared = lane.sharedLane === true ? ' SHARED LANE' : '';
2692
3117
  console.log(' ' + (lane.mine ? '* ' : ' ') + lane.branchName + ws
2693
- + ' (' + world + ') — ' + (lane.stagedCountAsOf || 0) + ' component(s)' + who + ttl);
3118
+ + ' (' + world + ') — overlay ' + (lane.stagedCountAsOf || 0) + workset + stageMode + shared + who + ttl);
2694
3119
  });
2695
3120
  console.log(' (* = this command\'s lane)');
2696
3121
  console.log(' A variant-branch lane layers over that branch\'s ComponentVariant overlays, and a commit');
@@ -2820,6 +3245,17 @@ async function statusComponentsCommand(flags) {
2820
3245
  throw new Error(response.message || 'Staging status failed');
2821
3246
  }
2822
3247
 
3248
+ await appendVerificationPacket(api, cwd, session, accountId, flags, {
3249
+ type: 'component_status',
3250
+ success: true,
3251
+ claim: 'Component status observed',
3252
+ world: buildCommandWorld(response, { accountId, dataMode, branchName, workspace, host: normalizeBaseUrl(baseUrl) }),
3253
+ revision: collectVerificationSource(cwd, flags),
3254
+ evidenceCategories: ['component_status'],
3255
+ rawRefs: { command: 'components status' },
3256
+ componentStatus: response
3257
+ }, { quiet: flagEnabled(flags.json) });
3258
+
2823
3259
  if (flagEnabled(flags.json)) {
2824
3260
  console.log(JSON.stringify(response, null, 2));
2825
3261
  return response;
@@ -2887,8 +3323,13 @@ async function clearComponentsCommand(flags) {
2887
3323
  return response;
2888
3324
  }
2889
3325
 
2890
- async function syncComponentsCommand(flags) {
3326
+ async function syncComponentsCommand(rawFlags) {
2891
3327
  const cwd = process.cwd();
3328
+ // `--safe` is a NAME for the gate combination that should be the default agent path, not a new gate.
3329
+ // Spelling out --summary --changed-only --fail-on-errors --fail-on-removed correctly every time is the
3330
+ // step agents skip, and skipping it is how a stale branch writes 34 unrelated ComponentVariant rows.
3331
+ const safe = flagEnabled(rawFlags.safe);
3332
+ const flags = safe ? expandSafeSyncFlags(rawFlags) : rawFlags;
2892
3333
  ensureLocalState(cwd);
2893
3334
  const sessionContext = resolveSessionContext(cwd, flags);
2894
3335
  const { session, accountId } = sessionContext;
@@ -2935,6 +3376,10 @@ async function syncComponentsCommand(flags) {
2935
3376
  };
2936
3377
 
2937
3378
  let preflightGate = null;
3379
+ // The flags the gates are actually evaluated with. `--safe` may resolve `--changed-since` for the
3380
+ // caller, and it must be the SAME value the preflight and the post-sync evaluation use, or the two
3381
+ // could disagree about the same plan.
3382
+ let gateFlags = flags;
2938
3383
  if (preflightRequested && !dryRun) {
2939
3384
  const statusResponse = await loggedPost(api, cwd, '/cli/components', {
2940
3385
  token: session.token,
@@ -2947,31 +3392,63 @@ async function syncComponentsCommand(flags) {
2947
3392
 
2948
3393
  const branchContext = statusResponse && statusResponse.branchContext;
2949
3394
  if (!branchContext || branchContext.onTrunk) {
2950
- throw new Error('components sync safety preflight requires a non-trunk variant branch because trunk sync has no dry-run plan. Re-run without safety flags only if you intend a full trunk reconcile.');
2951
- }
3395
+ // Trunk has no dry-run plan to gate on, and pretending otherwise would be worse than refusing:
3396
+ // an agent would read "safe" and get an ungated authoritative reconcile. Say what trunk sync is
3397
+ // and make the caller assert it.
3398
+ if (!safe) {
3399
+ throw new Error('components sync safety preflight requires a non-trunk variant branch because trunk sync has no dry-run plan. Re-run without safety flags only if you intend a full trunk reconcile.');
3400
+ }
3401
+ console.log('');
3402
+ console.log('TRUNK SYNC — there is no dry-run plan for trunk, so --safe cannot gate this.');
3403
+ console.log(' This is an AUTHORITATIVE full repo-to-DB reconcile of ' + (branchContext ? branchContext.trunkBranch || branchName : branchName) + ':');
3404
+ console.log(' every component row is rewritten from the pushed repo, and any live component');
3405
+ console.log(' missing from the repo is DELETED.');
3406
+ console.log(' For a gated plan, work on a variant branch instead.');
3407
+ if (!flagEnabled(flags.yes)) {
3408
+ throw new Error('components sync --safe on trunk requires --yes, because the plan cannot be previewed. ' +
3409
+ 'Re-run with --yes to accept the full trunk reconcile, or switch to a variant branch for a gated sync.');
3410
+ }
3411
+ // Drop the gates that can only be evaluated against a plan. Leaving them on would fail the command
3412
+ // AFTER an authoritative reconcile had already been written, which is the worst of both.
3413
+ gateFlags = Object.assign({}, flags, {
3414
+ 'changed-only': false, changedOnly: false,
3415
+ 'fail-on-removed': false, failOnRemoved: false
3416
+ });
3417
+ } else {
3418
+ gateFlags = resolveChangedSinceFlags(flags, cwd, branchContext, safe);
2952
3419
 
2953
- const previewResponse = await loggedPost(api, cwd, '/cli/components', Object.assign({}, syncPayload, {
2954
- dryRun: true
2955
- })).then((r) => r.data);
3420
+ const previewResponse = await loggedPost(api, cwd, '/cli/components', Object.assign({}, syncPayload, {
3421
+ dryRun: true
3422
+ })).then((r) => r.data);
2956
3423
 
2957
- if (!previewResponse.success) {
2958
- throw new Error(previewResponse.message || 'Server sync dry-run failed');
2959
- }
3424
+ if (!previewResponse.success) {
3425
+ throw new Error(previewResponse.message || 'Server sync dry-run failed');
3426
+ }
2960
3427
 
2961
- preflightGate = evaluateSyncGates(previewResponse, flags, changedFromWorkingTree, cwd);
3428
+ preflightGate = evaluateSyncGates(previewResponse, gateFlags, changedFromWorkingTree, cwd);
3429
+
3430
+ if (namesOnly) {
3431
+ printSessionResolutionWarning(sessionContext);
3432
+ printResolvedBaseUrl(baseUrl);
3433
+ console.log('Data mode:', previewResponse.dataMode || dataMode);
3434
+ console.log('Mode:', 'sync dry-run');
3435
+ console.log('Dry run: no variants were written and staging was not cleared.');
3436
+ printSyncNames(previewResponse);
3437
+ failOnSyncGate(preflightGate);
3438
+ return previewResponse;
3439
+ }
2962
3440
 
2963
- if (namesOnly) {
2964
- printSessionResolutionWarning(sessionContext);
2965
- printResolvedBaseUrl(baseUrl);
2966
- console.log('Data mode:', previewResponse.dataMode || dataMode);
2967
- console.log('Mode:', 'sync dry-run');
2968
- console.log('Dry run: no variants were written and staging was not cleared.');
2969
- printSyncNames(previewResponse);
2970
3441
  failOnSyncGate(preflightGate);
2971
- return previewResponse;
2972
- }
2973
3442
 
2974
- failOnSyncGate(preflightGate);
3443
+ // The plan passed every gate. Show it before mutating anyway: a gate proves the plan matches the
3444
+ // changed set, not that the changed set is what the author meant.
3445
+ if (safe && !flagEnabled(flags.yes)) {
3446
+ console.log('');
3447
+ console.log('Planned durable writes (gates passed):');
3448
+ printSyncNames(previewResponse);
3449
+ console.log('');
3450
+ }
3451
+ }
2975
3452
  }
2976
3453
 
2977
3454
  // Through the same refusal-aware door as `stage`: the landing lease and the account's commit rules both
@@ -2983,9 +3460,26 @@ async function syncComponentsCommand(flags) {
2983
3460
  }
2984
3461
 
2985
3462
  const summary = buildSyncSummary(response);
2986
- const gate = preflightGate || evaluateSyncGates(response, flags, changedFromWorkingTree, cwd);
3463
+ const gate = preflightGate || evaluateSyncGates(response, gateFlags, changedFromWorkingTree, cwd);
2987
3464
  summary.gates = gate.checks;
2988
3465
 
3466
+ await appendVerificationPacket(api, cwd, session, accountId, flags, {
3467
+ type: response.sync && response.sync.dryRun ? 'sync_dry_run' : 'sync_mutation',
3468
+ success: response.success !== false,
3469
+ claim: response.sync && response.sync.dryRun ? 'Component sync dry-run observed' : 'Component sync completed',
3470
+ world: buildCommandWorld(response, { accountId, dataMode, branchName, workspace, host: normalizeBaseUrl(baseUrl), sourceLayer: response.sync && response.sync.mode === 'variant' ? 'variant' : 'trunk' }),
3471
+ revision: Object.assign(collectVerificationSource(cwd, flags), {
3472
+ platformSyncedSha: response.sync && (response.sync.postSyncSha || response.sync.branchHeadSha)
3473
+ }),
3474
+ evidenceCategories: [response.sync && response.sync.dryRun ? 'sync_dry_run' : 'sync_mutation'],
3475
+ assertions: [
3476
+ { category: response.sync && response.sync.dryRun ? 'sync_dry_run' : 'sync_mutation', summary }
3477
+ ],
3478
+ rawRefs: { command: 'components sync' },
3479
+ sync: response.sync,
3480
+ summary
3481
+ }, { quiet: flagEnabled(flags.json) });
3482
+
2989
3483
  if (flagEnabled(flags.json)) {
2990
3484
  console.log(JSON.stringify(flagEnabled(flags.summary) ? summary : response, null, 2));
2991
3485
  failOnSyncGate(gate);
@@ -3093,8 +3587,73 @@ function printPromotionSignals(response) {
3093
3587
  });
3094
3588
  }
3095
3589
 
3590
+ /**
3591
+ * `--safe`: the gate combination that should be the normal agent path for a variant-branch sync.
3592
+ *
3593
+ * Named rather than remembered. The individual flags have existed for a while and the failure they exist
3594
+ * to stop — a stale branch whose old copies of untouched files become unrelated ComponentVariant
3595
+ * overrides — happens precisely when somebody forgets one of them.
3596
+ *
3597
+ * `--expected-removed` already implies fail-on-removed and NAMES the removals the caller accepts, so
3598
+ * --safe must not layer a blanket --fail-on-removed on top of it and contradict the narrower answer.
3599
+ */
3600
+ function expandSafeSyncFlags(flags) {
3601
+ const expectedRemoved = flags['expected-removed'] != null ? flags['expected-removed'] : flags.expectedRemoved;
3602
+ return Object.assign({}, flags, {
3603
+ summary: true,
3604
+ 'fail-on-errors': true,
3605
+ 'changed-only': true,
3606
+ 'fail-on-removed': expectedRemoved != null ? flags['fail-on-removed'] : true
3607
+ });
3608
+ }
3609
+
3610
+ /**
3611
+ * The base ref the `--changed-only` gate compares the server plan against.
3612
+ *
3613
+ * `--changed-only` alone reads UNCOMMITTED edits, and the documented flow commits and pushes BEFORE the
3614
+ * sync (the server syncs by pulling the remote, so it cannot see uncommitted work at all). So at the
3615
+ * moment the gate runs, the working tree is usually clean and the changed set is empty — which used to
3616
+ * make the gate refuse every plan.
3617
+ *
3618
+ * Under --safe the ref is derived from the branch's own merge base with trunk when the caller did not
3619
+ * name one. Local refs only: no implicit `git fetch`, because a status/gate command that quietly reaches
3620
+ * the network is a command whose cost and failure modes the caller did not agree to. When nothing local
3621
+ * can answer, this refuses with the command to run rather than gating against an empty set.
3622
+ */
3623
+ function resolveChangedSinceFlags(flags, cwd, branchContext, safe) {
3624
+ const explicit = flags['changed-since'] || flags.changedSince;
3625
+ if (explicit || !safe) {
3626
+ return flags;
3627
+ }
3628
+ const trunk = (branchContext && branchContext.trunkBranch) || 'main';
3629
+ const candidates = ['origin/' + trunk, trunk];
3630
+ for (const candidate of candidates) {
3631
+ let base = null;
3632
+ try {
3633
+ base = execSync('git merge-base HEAD ' + JSON.stringify(candidate), {
3634
+ cwd,
3635
+ stdio: ['ignore', 'pipe', 'pipe']
3636
+ }).toString().trim();
3637
+ } catch (_) {
3638
+ continue;
3639
+ }
3640
+ if (base) {
3641
+ console.log('--safe: comparing the sync plan against everything this branch added since ' +
3642
+ candidate + ' (' + base.slice(0, 12) + ').');
3643
+ return Object.assign({}, flags, { 'changed-since': base });
3644
+ }
3645
+ }
3646
+ throw new Error(
3647
+ 'components sync --safe needs a base ref to compare the plan against, and neither origin/' + trunk +
3648
+ ' nor ' + trunk + ' could be resolved locally.\n' +
3649
+ ' Run `git fetch origin ' + trunk + '` and retry, or pass it yourself:\n' +
3650
+ ' remits-cli components sync --safe --changed-since origin/' + trunk
3651
+ );
3652
+ }
3653
+
3096
3654
  function syncPreflightRequested(flags) {
3097
3655
  return Boolean(
3656
+ flagEnabled(flags.safe) ||
3098
3657
  flagEnabled(flags['names-only']) ||
3099
3658
  flagEnabled(flags.namesOnly) ||
3100
3659
  flagEnabled(flags['changed-only']) ||
@@ -3214,7 +3773,16 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
3214
3773
  ? '\n The changed set is EMPTY: --changed-only reads UNCOMMITTED edits, and the documented flow ' +
3215
3774
  'commits and pushes before syncing. Pass --changed-since <ref> (e.g. the commit you branched ' +
3216
3775
  'from, or origin/main) so the gate can see committed work.'
3217
- : '';
3776
+ // A plan that reaches well beyond a real changed set is almost always a STALE BRANCH: it still
3777
+ // physically carries old copies of files nobody on it touched, and a variant sync turns each of
3778
+ // those into an unrelated override. Naming the cause is the difference between a gate that
3779
+ // stops the damage and a gate that also tells you how to clear it.
3780
+ : (changedSet.length && unexpected.length > changedSet.length)
3781
+ ? '\n Most likely cause: this branch is BEHIND trunk and still carries old copies of files ' +
3782
+ 'it never changed. A variant sync turns each of those into an unrelated override.' +
3783
+ '\n Next step: merge trunk into this branch, push, then re-run the same command:' +
3784
+ '\n git merge <trunk> && git push origin <branch>'
3785
+ : '';
3218
3786
  violations.push('--changed-only: plan touches ' + unexpected.length + ' component(s) this checkout did not change: ' +
3219
3787
  unexpected.slice(0, 20).map(syncEntryToken).join(', ') + (unexpected.length > 20 ? ', ...' : '') + hint);
3220
3788
  }
@@ -3717,6 +4285,14 @@ async function commitComponentsCommand(flags) {
3717
4285
  }
3718
4286
 
3719
4287
  console.log('Phase 2/3: server sync');
4288
+ if (flagEnabled(flags.safe)) {
4289
+ // The honest limit, stated where it applies. `components commit` runs git add/commit/push BEFORE the
4290
+ // server sync, so a --safe gate here cannot stop the push — it stops the PLATFORM from writing bad
4291
+ // overlays. The branch may already be pushed when the gate refuses; that is recoverable, an
4292
+ // unintended ComponentVariant tree is much less so.
4293
+ console.log(' --safe: the branch is already pushed. The gate below stops the platform from WRITING a');
4294
+ console.log(' surprising plan; it cannot un-push. Use `components sync --safe` alone for a pre-push gate.');
4295
+ }
3720
4296
  const syncResponse = await syncComponentsCommand({
3721
4297
  ...flags,
3722
4298
  branch: branchName,
@@ -4026,6 +4602,30 @@ async function testCommand(flags) {
4026
4602
  } else if (status.result && status.result.failed > 0) {
4027
4603
  process.exitCode = 1;
4028
4604
  }
4605
+
4606
+ await appendVerificationPacket(api, cwd, session, accountId, flags, {
4607
+ type: 'test_run',
4608
+ success: status.status === 'completed' && !(status.result && status.result.failed > 0) && !unmatched.length,
4609
+ claim: 'Test run ' + String(testRef),
4610
+ world: buildCommandWorld(status, { accountId, dataMode: status.dataMode || dataMode, dataModeSource, branchName, workspace, host: normalizeBaseUrl(baseUrl), sourceLayer: status.staging && status.staging.testComponentSource }),
4611
+ revision: Object.assign(collectVerificationSource(cwd, flags), {
4612
+ compileSignatures: status.staging && status.staging.testComponentSignature ? [status.staging.testComponentSignature] : []
4613
+ }),
4614
+ test: {
4615
+ taskId: start.taskId,
4616
+ testId: status.test && status.test.id,
4617
+ testName: status.test && status.test.name,
4618
+ selectedCases: names,
4619
+ passed: status.result && status.result.passed,
4620
+ failed: status.result && status.result.failed,
4621
+ dataModeSource: status.dataModeSource || dataModeSource,
4622
+ unmatchedTestNames: unmatched
4623
+ },
4624
+ evidenceCategories: ['test_run'].concat(names.map((name) => 'test_run.' + name)),
4625
+ limitations: unmatched.length ? ['One or more requested test case selectors matched no case.'] : [],
4626
+ rawRefs: { testStatusKey: start.taskId },
4627
+ status
4628
+ });
4029
4629
  }
4030
4630
 
4031
4631
  async function tokenCommand(flags) {
@@ -4114,6 +4714,21 @@ async function tokenCommand(flags) {
4114
4714
 
4115
4715
  printSessionResolutionWarning(sessionContext);
4116
4716
  console.log(JSON.stringify(output, null, 2));
4717
+
4718
+ await appendVerificationPacket(api, cwd, session, accountId, flags, {
4719
+ type: 'token_inspect',
4720
+ success: true,
4721
+ claim: 'Verification token minted',
4722
+ world: buildCommandWorld(output, { accountId: output.accountId || accountId, dataMode: output.dataMode || dataMode, branchName, workspace: output.workspace, host: normalizeBaseUrl(baseUrl) }),
4723
+ revision: collectVerificationSource(cwd, flags),
4724
+ browser: {
4725
+ tokenKeyHash: output.tokenKey ? sha256(String(output.tokenKey)) : null,
4726
+ urlHash: output.embeddableUrl ? sha256(String(output.embeddableUrl)) : null,
4727
+ liveUrl: flagEnabled(flags['store-url']) ? output.embeddableUrl : undefined
4728
+ },
4729
+ evidenceCategories: ['token_inspect'],
4730
+ token: Object.assign({}, output, { tokenKey: output.tokenKey ? '[redacted]' : undefined, embedTokenKey: output.embedTokenKey ? '[redacted]' : undefined })
4731
+ });
4117
4732
  }
4118
4733
 
4119
4734
  async function tokenInspectCommand(flags) {
@@ -4143,6 +4758,19 @@ async function tokenInspectCommand(flags) {
4143
4758
  printSessionResolutionWarning(sessionContext);
4144
4759
  printResolvedBaseUrl(baseUrl);
4145
4760
  console.log(JSON.stringify(data, null, 2));
4761
+
4762
+ await appendVerificationPacket(api, cwd, session, accountId, flags, {
4763
+ type: 'token_inspect',
4764
+ success: true,
4765
+ claim: 'Token inspected',
4766
+ world: buildCommandWorld(data.safety || {}, { accountId: data.context && data.context.accountId || accountId, dataMode: data.safety && data.safety.dataMode, host: normalizeBaseUrl(baseUrl) }),
4767
+ revision: collectVerificationSource(cwd, flags),
4768
+ browser: {
4769
+ tokenKeyHash: data.token && data.token.tokenKey ? sha256(String(data.token.tokenKey)) : null
4770
+ },
4771
+ evidenceCategories: ['token_inspect'],
4772
+ tokenInspect: data
4773
+ });
4146
4774
  }
4147
4775
 
4148
4776
  async function toolsCommand(flags) {
@@ -4313,6 +4941,17 @@ async function toolCommand(flags) {
4313
4941
  console.log('Tool error:', data.toolMessage || toolResultMessage(data.result));
4314
4942
  }
4315
4943
  if (data.status === 'failed' || polledFailed) process.exitCode = 1;
4944
+ await appendVerificationPacket(api, cwd, session, accountId, flags, {
4945
+ type: 'tool_call',
4946
+ success: data.status !== 'failed' && !polledFailed,
4947
+ claim: 'Tool status ' + requestedCallId,
4948
+ world: buildCommandWorld(data, { accountId: statusAccountId, dataMode: data.dataMode || dataMode, branchName: flags.branch || (stored && stored.branchName) || branchName, host: normalizeBaseUrl(baseUrl) }),
4949
+ revision: collectVerificationSource(cwd, flags),
4950
+ evidenceCategories: ['tool_call'],
4951
+ rawRefs: { toolResponsePath: statusResponse.responseFile, callId: requestedCallId },
4952
+ tool: { callId: requestedCallId, status: data.status, name: stored && stored.name },
4953
+ result: data
4954
+ });
4316
4955
  return;
4317
4956
  }
4318
4957
 
@@ -4395,6 +5034,274 @@ async function toolCommand(flags) {
4395
5034
  process.exitCode = 1;
4396
5035
  }
4397
5036
  }
5037
+
5038
+ await appendVerificationPacket(api, cwd, session, accountId, flags, {
5039
+ type: 'tool_call',
5040
+ success: !toolFailed && (!asyncMode || !waitForAsync || process.exitCode !== 1),
5041
+ claim: 'Tool call ' + String(toolName),
5042
+ world: buildCommandWorld(data, { accountId: data.accountId || accountId, dataMode: data.dataMode || dataMode, branchName: data.branchName || branchName, workspace: resolveWorkspace(cwd, flags), host: normalizeBaseUrl(baseUrl), sourceLayer: data.componentSource }),
5043
+ revision: Object.assign(collectVerificationSource(cwd, flags), {
5044
+ compileSignatures: data.componentSignature ? [data.componentSignature] : []
5045
+ }),
5046
+ evidenceCategories: ['tool_call'],
5047
+ dependencies: {
5048
+ mocks: [],
5049
+ liveHttpCalls: data.liveHttpCalls || []
5050
+ },
5051
+ rawRefs: { toolResponsePath: response.responseFile, callId },
5052
+ tool: { callId, name: String(toolName), async: asyncMode, status: data.status, toolSuccess: data.toolSuccess },
5053
+ result: data
5054
+ });
5055
+ }
5056
+
5057
+ async function verifyCommand(flags, subcommand) {
5058
+ const cwd = process.cwd();
5059
+ ensureLocalState(cwd);
5060
+ const sub = String(subcommand || (flags._ && flags._[1]) || 'status').toLowerCase();
5061
+
5062
+ if (sub === 'current') {
5063
+ const active = readActiveVerificationEnvelope(cwd);
5064
+ if (!active) {
5065
+ console.log('No active verification envelope in this checkout.');
5066
+ return;
5067
+ }
5068
+ console.log('Active verification envelope:', active);
5069
+ const local = readLocalVerificationEnvelope(cwd, active);
5070
+ if (local && local.summary) console.log('Summary:', local.summary);
5071
+ return;
5072
+ }
5073
+
5074
+ if (sub === 'use') {
5075
+ const envelopeId = flags._ && flags._[2] || flags.envelope || flags['verify-envelope'];
5076
+ if (!envelopeId) throw new Error('Usage: remits-cli verify use <envelopeId>');
5077
+ writeActiveVerificationEnvelope(cwd, envelopeId);
5078
+ console.log('Active verification envelope:', envelopeId);
5079
+ return;
5080
+ }
5081
+
5082
+ if (sub === 'clear') {
5083
+ writeActiveVerificationEnvelope(cwd, null);
5084
+ console.log('Verification envelope cleared for this checkout.');
5085
+ return;
5086
+ }
5087
+
5088
+ const sessionContext = resolveSessionContext(cwd, flags);
5089
+ const { session, accountId } = sessionContext;
5090
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
5091
+ const dataMode = resolveDataMode(flags, session);
5092
+ const branchName = flags.branch || currentBranch(cwd);
5093
+ const workspace = resolveWorkspace(cwd, flags);
5094
+ const api = buildAxios(baseUrl, session.token);
5095
+
5096
+ if (sub === 'start') {
5097
+ const summary = flags.summary || flags._ && flags._.slice(2).join(' ');
5098
+ if (!summary) throw new Error('verify start requires --summary "..."');
5099
+ let manifest = {};
5100
+ if (flags.manifest || flags.file) {
5101
+ manifest = parseManifestFile(flags.manifest || flags.file);
5102
+ }
5103
+ let statusResponse = null;
5104
+ try {
5105
+ statusResponse = await loggedPost(api, cwd, '/cli/components', {
5106
+ token: session.token,
5107
+ accountId,
5108
+ branchName,
5109
+ workspace,
5110
+ dataMode,
5111
+ mode: 'status'
5112
+ }).then((r) => r.data);
5113
+ } catch (_) { /* best-effort */ }
5114
+
5115
+ const source = collectVerificationSource(cwd, flags);
5116
+ if (statusResponse) {
5117
+ source.componentBranch = statusResponse.branchContext && statusResponse.branchContext.onTrunk ? 'trunk' : branchName;
5118
+ source.sourceLayer = statusResponse.laneSummary && statusResponse.laneSummary.stagedCount > 0
5119
+ ? 'staged' : (statusResponse.branchContext && statusResponse.branchContext.onTrunk ? 'trunk' : 'variant');
5120
+ source.stagedOverlayHash = statusResponse.laneSummary ? sha256(stableStringify(statusResponse.laneSummary)) : null;
5121
+ }
5122
+
5123
+ const response = await postVerificationCommand(api, cwd, session, accountId, 'start', {
5124
+ summary,
5125
+ ticketId: flags.ticket,
5126
+ account: collectVerificationAccount(cwd, accountId, baseUrl, dataMode),
5127
+ source,
5128
+ manifest
5129
+ });
5130
+ const envelope = response.envelope;
5131
+ writeLocalVerificationEnvelope(cwd, envelope);
5132
+ writeActiveVerificationEnvelope(cwd, envelope.envelopeId);
5133
+ console.log('Verification envelope started:', envelope.envelopeId);
5134
+ console.log('Target:', 'account=' + accountId + ', branch=' + branchName + ', workspace=' + (workspace || 'shared') + ', dataMode=' + dataMode);
5135
+ console.log('Local mirror:', verificationPaths(cwd, envelope.envelopeId).base);
5136
+ return envelope;
5137
+ }
5138
+
5139
+ const envelopeId = flags.envelope || flags['verify-envelope'] || flags.id || readActiveVerificationEnvelope(cwd);
5140
+ if (!envelopeId) {
5141
+ throw new Error('No verification envelope selected. Run `remits-cli verify start --summary "..."` or pass --envelope <id>.');
5142
+ }
5143
+
5144
+ if (sub === 'manifest') {
5145
+ if (flagEnabled(flags.print)) {
5146
+ const local = readLocalVerificationEnvelope(cwd, envelopeId);
5147
+ const response = await postVerificationCommand(api, cwd, session, accountId, 'show', { envelopeId })
5148
+ .catch(() => ({ envelope: local }));
5149
+ const manifest = response.envelope && response.envelope.acceptance;
5150
+ console.log(JSON.stringify(manifest || {}, null, 2));
5151
+ return manifest;
5152
+ }
5153
+ const file = flags.file || flags.manifest;
5154
+ if (!file) throw new Error('verify manifest requires --file <manifest.json> or --print');
5155
+ const manifest = parseManifestFile(file);
5156
+ const response = await postVerificationCommand(api, cwd, session, accountId, 'manifest', { envelopeId, manifest });
5157
+ writeLocalVerificationEnvelope(cwd, response.envelope);
5158
+ console.log('Manifest attached to envelope:', envelopeId);
5159
+ console.log('Required evidence:', (((response.envelope || {}).acceptance || {}).requiredEvidence || []).length);
5160
+ return response.envelope;
5161
+ }
5162
+
5163
+ if (sub === 'show' || sub === 'read' || sub === 'get' || sub === 'packets') {
5164
+ const response = await postVerificationCommand(api, cwd, session, accountId, 'show', { envelopeId });
5165
+ if (response.envelope) writeLocalVerificationEnvelope(cwd, response.envelope);
5166
+ if (flagEnabled(flags.json)) {
5167
+ console.log(JSON.stringify(response.envelope || response, null, 2));
5168
+ return response.envelope || response;
5169
+ }
5170
+ const envelope = response.envelope || {};
5171
+ console.log('Verification envelope:', envelope.envelopeId || envelopeId);
5172
+ if (envelope.summary) console.log('Summary:', envelope.summary);
5173
+ console.log('Status:', envelope.status || 'unknown');
5174
+ const packets = Array.isArray(envelope.packets) ? envelope.packets : [];
5175
+ console.log('Packets:', packets.length);
5176
+ packets.forEach((packet) => {
5177
+ console.log('- ' + (packet.type || 'unknown') + ' ' + (packet.packetId || '') + (packet.success === false ? ' failed' : ''));
5178
+ });
5179
+ return envelope;
5180
+ }
5181
+
5182
+ if (sub === 'attach') {
5183
+ const artifacts = [];
5184
+ let type = 'manual_observation';
5185
+ const label = flags.label || flags.name || 'attached evidence';
5186
+ if (flags.artifact) {
5187
+ type = 'artifact';
5188
+ artifacts.push(Object.assign({ label, kind: 'artifact', source: 'local_path' }, sha256File(flags.artifact)));
5189
+ }
5190
+ if (flags.screenshot) {
5191
+ type = 'artifact';
5192
+ artifacts.push(Object.assign({ label, kind: 'screenshot', source: 'local_path' }, sha256File(flags.screenshot)));
5193
+ }
5194
+ const note = flags.note || flags.message;
5195
+ if (!artifacts.length && !note) {
5196
+ throw new Error('verify attach requires --artifact <path>, --screenshot <path>, or --note "..."');
5197
+ }
5198
+ const packet = {
5199
+ type,
5200
+ success: true,
5201
+ claim: note || label,
5202
+ artifacts,
5203
+ evidenceCategories: artifacts.map((a) => a.kind === 'screenshot' ? 'screenshot' : 'artifact.sha256')
5204
+ };
5205
+ const response = await appendVerificationPacket(api, cwd, session, accountId, { 'verify-envelope': envelopeId }, packet);
5206
+ console.log('Evidence attached:', (response.packet || packet).packetId);
5207
+ return response;
5208
+ }
5209
+
5210
+ if (sub === 'browser-start' || sub === 'browser-step' || sub === 'browser-snapshot') {
5211
+ const url = flags.url || flags.token || flags._ && flags._[2];
5212
+ const action = flags.action || (sub === 'browser-start' ? 'open' : (sub === 'browser-snapshot' ? 'snapshot' : 'observe'));
5213
+ const packet = {
5214
+ type: sub === 'browser-start' ? 'browser_session' : (sub === 'browser-snapshot' ? 'browser_snapshot' : 'browser_step'),
5215
+ success: !flagEnabled(flags.failed),
5216
+ claim: flags.claim || flags.label || action,
5217
+ world: buildCommandWorld(null, { accountId, dataMode, branchName, workspace, host: normalizeBaseUrl(baseUrl) }),
5218
+ browser: {
5219
+ action,
5220
+ target: flags.target,
5221
+ value: flags.value,
5222
+ urlHash: url ? sha256(String(url)) : null,
5223
+ liveUrl: flagEnabled(flags['store-url']) ? url : undefined,
5224
+ interactionKind: flags.kind || (['click', 'fill', 'select', 'upload', 'reload'].includes(String(action)) ? 'user_interaction' : 'observation')
5225
+ },
5226
+ evidenceCategories: [sub, action === 'upload' ? 'browser_session.actual_upload' : null, action === 'click' ? 'browser_session.actual_clicks' : null]
5227
+ .filter(Boolean)
5228
+ };
5229
+ const response = await appendVerificationPacket(api, cwd, session, accountId, { 'verify-envelope': envelopeId }, packet);
5230
+ console.log('Browser evidence attached:', (response.packet || packet).packetId);
5231
+ return response;
5232
+ }
5233
+
5234
+ if (sub === 'test') {
5235
+ return await testCommand(Object.assign({}, flags, { 'verify-envelope': envelopeId }));
5236
+ }
5237
+ if (sub === 'token') {
5238
+ return await tokenCommand(Object.assign({}, flags, { 'verify-envelope': envelopeId }));
5239
+ }
5240
+ if (sub === 'stage') {
5241
+ return await pushComponentsCommand(Object.assign({}, flags, { 'verify-envelope': envelopeId }));
5242
+ }
5243
+ if (sub === 'sync') {
5244
+ return await syncComponentsCommand(Object.assign({}, flags, { 'verify-envelope': envelopeId }));
5245
+ }
5246
+ if (sub === 'tool') {
5247
+ return await toolCommand(Object.assign({}, flags, { 'verify-envelope': envelopeId }));
5248
+ }
5249
+
5250
+ if (sub === 'invalidate') {
5251
+ const reason = flags.reason || flags.message || flags._ && flags._.slice(2).join(' ') || 'manually invalidated';
5252
+ const response = await postVerificationCommand(api, cwd, session, accountId, 'invalidate', { envelopeId, reason });
5253
+ writeLocalVerificationEnvelope(cwd, response.envelope);
5254
+ console.log('Envelope marked stale:', envelopeId);
5255
+ return response.envelope;
5256
+ }
5257
+
5258
+ if (sub === 'status' || sub === 'report') {
5259
+ let statusPacket = null;
5260
+ try {
5261
+ const componentStatus = await loggedPost(api, cwd, '/cli/components', {
5262
+ token: session.token,
5263
+ accountId,
5264
+ branchName,
5265
+ workspace,
5266
+ dataMode,
5267
+ mode: 'status'
5268
+ }).then((r) => r.data);
5269
+ statusPacket = {
5270
+ type: 'component_status',
5271
+ success: componentStatus.success !== false,
5272
+ claim: 'Current component status for verification status check',
5273
+ world: buildCommandWorld(componentStatus, { accountId, dataMode, branchName, workspace, host: normalizeBaseUrl(baseUrl) }),
5274
+ revision: collectVerificationSource(cwd, flags),
5275
+ assertions: [],
5276
+ rawRefs: { command: 'components status' },
5277
+ componentStatus
5278
+ };
5279
+ await appendVerificationPacket(api, cwd, session, accountId, { 'verify-envelope': envelopeId }, statusPacket, { quiet: true });
5280
+ } catch (err) {
5281
+ if (!flagEnabled(flags.json)) {
5282
+ console.error('Warning: could not attach component_status packet before rendering report:', describeError(err));
5283
+ }
5284
+ }
5285
+ const response = await postVerificationCommand(api, cwd, session, accountId, sub, {
5286
+ envelopeId,
5287
+ currentWorld: buildCommandWorld(statusPacket && statusPacket.componentStatus, {
5288
+ accountId,
5289
+ dataMode,
5290
+ branchName,
5291
+ workspace,
5292
+ host: normalizeBaseUrl(baseUrl)
5293
+ })
5294
+ });
5295
+ if (response.envelope) writeLocalVerificationEnvelope(cwd, response.envelope);
5296
+ if (response.report) {
5297
+ console.log(response.report);
5298
+ } else if (response.evaluation || response.status) {
5299
+ console.log(JSON.stringify(response, null, 2));
5300
+ }
5301
+ return response;
5302
+ }
5303
+
5304
+ throw new Error('Unknown verify subcommand: ' + sub);
4398
5305
  }
4399
5306
 
4400
5307
  async function dataModeCommand(flags, subcommand) {
@@ -9854,6 +10761,13 @@ function printComponentsHelp(subcommand) {
9854
10761
  console.log('--summary prints compact counts, removals/tombstones, errors, skipped items, and warnings.');
9855
10762
  console.log('');
9856
10763
  console.log('Agent safety gates (each exits non-zero instead of printing a wall of JSON):');
10764
+ console.log(' --safe THE RECOMMENDED PATH on a variant branch. Expands to');
10765
+ console.log(' --summary --changed-only --fail-on-errors --fail-on-removed,');
10766
+ console.log(' resolves --changed-since from the merge base with trunk when you');
10767
+ console.log(' did not name one (local refs only, no implicit fetch), and prints');
10768
+ console.log(' the planned writes before mutating unless --yes is passed.');
10769
+ console.log(' On TRUNK there is no plan to gate, so it explains what a trunk');
10770
+ console.log(' reconcile does and requires --yes.');
9857
10771
  console.log(' --changed-only dry-run first; fail unless every planned write is a component this checkout changed');
9858
10772
  console.log(' --changed-since <ref> pair with --changed-only after committing: the changed set becomes');
9859
10773
  console.log(' the components touched between <ref> and HEAD, plus uncommitted edits');
@@ -9865,14 +10779,29 @@ function printComponentsHelp(subcommand) {
9865
10779
  return;
9866
10780
  }
9867
10781
  if (subcommand === 'stage' || subcommand === 'push') {
9868
- console.log('Usage: remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--changed-only] [--data-mode test|prod] [--json|--verbose]');
10782
+ console.log('Usage: remits-cli components stage [--workset | --changed-only] [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--data-mode test|prod] [--json|--verbose]');
9869
10783
  console.log('');
9870
10784
  console.log('Stages local component files into the Redis staging cache. It never writes the database or git.');
9871
- console.log('Terminal output separates local working-tree component deltas from the full materialized staging cache count.');
10785
+ console.log('');
10786
+ console.log('Three stage modes, and the difference decides what a run in this lane resolves:');
10787
+ console.log(' (default) FULL SNAPSHOT — uploads the whole repo manifest and reconciles the lane');
10788
+ console.log(' to it. Correct as a complete snapshot and as a "what is stale?" reset;');
10789
+ console.log(' a poor progress signal, because the lane then holds every component.');
10790
+ console.log(' --workset WORKSET REPLACE — uploads only the components git reports changed and');
10791
+ console.log(' reconciles the lane to exactly them. THE NORMAL ITERATION MODE: the');
10792
+ console.log(' lane holds your work and nothing else, so the console shows what you');
10793
+ console.log(' are actually doing. An empty workset is refused, never a silent clear.');
10794
+ console.log(' --changed-only WORKSET MERGE — uploads only the changed components and leaves every');
10795
+ console.log(' other staged entry in place. Keeps its long-standing semantics, so it');
10796
+ console.log(' cannot shrink a lane inherited from an earlier full snapshot; the');
10797
+ console.log(' command warns when entries are retained that way.');
9872
10798
  console.log('');
9873
10799
  console.log(' --workspace NAME stage into an isolated lane on this branch (see `remits-cli workspace`).');
9874
- console.log(' --changed-only stage only the components this working tree edited, and do NOT reconcile');
9875
- console.log(' (entries for untouched components are left alone rather than deleted).');
10800
+ console.log(' --empty-workset clear with --workset, let an empty workset clear the lane (default: refuse).');
10801
+ console.log(' --replace-lane with --changed-only, the explicit spelling of --workset.');
10802
+ console.log('');
10803
+ console.log('Output separates three different numbers: the WORKSET git reports, what was SUBMITTED, and');
10804
+ console.log('the materialized OVERLAY the lane now holds — which is the one a run resolves.');
9876
10805
  return;
9877
10806
  }
9878
10807
  if (subcommand === 'status') {
@@ -9893,21 +10822,26 @@ function printComponentsHelp(subcommand) {
9893
10822
  return;
9894
10823
  }
9895
10824
  if (subcommand === 'commit') {
9896
- console.log('Usage: remits-cli components commit [--message "msg"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
10825
+ console.log('Usage: remits-cli components commit [--safe] [--message "msg"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
9897
10826
  console.log('');
9898
10827
  console.log('Runs local git add/commit/push, then server sync. Prefer explicit git + components sync when you need inspectable phases.');
9899
10828
  console.log('components commit does not support --dry-run.');
10829
+ console.log('');
10830
+ console.log('--safe passes the sync gates through to phase 2. The honest limit: git push happens in phase 1,');
10831
+ console.log('so the gate stops the PLATFORM from writing a surprising plan — it cannot un-push the branch.');
10832
+ console.log('For a gate that runs before anything leaves your machine, push yourself and use');
10833
+ console.log('`remits-cli components sync --safe`.');
9900
10834
  return;
9901
10835
  }
9902
10836
  console.log('Usage: remits-cli components <stage|status|clear|sync|commit|promotion|branches|branch>');
9903
10837
  console.log('');
9904
10838
  console.log('All staging subcommands accept --workspace NAME to isolate a lane on the same branch,');
9905
10839
  console.log('so several agents can iterate at once. See: remits-cli workspace --help');
9906
- console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
10840
+ console.log(' remits-cli components stage [--workset|--changed-only] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
9907
10841
  console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
9908
10842
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
9909
- console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
9910
- console.log(' remits-cli components commit [--message "msg"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
10843
+ console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
10844
+ console.log(' remits-cli components commit [--safe] [--message "msg"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
9911
10845
  console.log('');
9912
10846
  console.log(' --force-tombstones applies only on non-trunk variant syncs, when a missing trunk');
9913
10847
  console.log(' component file is intentionally being recorded as a tombstone override.');
@@ -9979,6 +10913,35 @@ function printTokenHelp() {
9979
10913
  console.log('Inspect decodes a persisted Remits token and prints token metadata, recognized routing fields, safety/dataMode evidence, and full context.');
9980
10914
  }
9981
10915
 
10916
+ function printVerifyHelp() {
10917
+ console.log('Usage: remits-cli verify <subcommand>');
10918
+ console.log('');
10919
+ console.log('Envelope lifecycle:');
10920
+ console.log(' remits-cli verify start --summary "..." [--manifest file.json] [--ticket ID]');
10921
+ console.log(' remits-cli verify use <envelopeId>');
10922
+ console.log(' remits-cli verify current');
10923
+ console.log(' remits-cli verify clear');
10924
+ console.log(' remits-cli verify show [--envelope ID] [--json]');
10925
+ console.log(' remits-cli verify status [--envelope ID]');
10926
+ console.log(' remits-cli verify report [--envelope ID]');
10927
+ console.log('');
10928
+ console.log('Manifest and manual evidence:');
10929
+ console.log(' remits-cli verify manifest --file file.json [--envelope ID]');
10930
+ console.log(' remits-cli verify manifest --print [--envelope ID]');
10931
+ console.log(' remits-cli verify attach --artifact path --label "..." [--envelope ID]');
10932
+ console.log(' remits-cli verify attach --screenshot path --label "..." [--envelope ID]');
10933
+ console.log(' remits-cli verify attach --note "..." [--envelope ID]');
10934
+ console.log('');
10935
+ console.log('Wrappers around existing commands:');
10936
+ console.log(' remits-cli verify stage --workset');
10937
+ console.log(' remits-cli verify test --test "..." [--names "case"]');
10938
+ console.log(' remits-cli verify token --path /page');
10939
+ console.log(' remits-cli verify sync --safe');
10940
+ console.log(' remits-cli verify tool --name mcp_tool --input \'{...}\'');
10941
+ console.log('');
10942
+ console.log('Existing commands also accept --verify-envelope <id> and --no-verify-envelope.');
10943
+ }
10944
+
9982
10945
  async function main() {
9983
10946
  migrateSessionIfNeeded();
9984
10947
  const originalArgv = process.argv.slice(2);
@@ -10029,7 +10992,7 @@ async function main() {
10029
10992
  // sure the repo has the latest guides — authenticating first if needed — so
10030
10993
  // only platform-authenticated users ever receive them. `auth` syncs its own
10031
10994
  // guides, so it is excluded here.
10032
- const GUIDE_SYNC_COMMANDS = new Set(['components', 'test', 'token', 'tools', 'tool']);
10995
+ const GUIDE_SYNC_COMMANDS = new Set(['components', 'test', 'token', 'tools', 'tool', 'verify']);
10033
10996
  if (!wantsJson && !isHelpCmd && GUIDE_SYNC_COMMANDS.has(command)) {
10034
10997
  try { await ensureGuidesSynced(process.cwd(), args); } catch (_) { /* best-effort */ }
10035
10998
  }
@@ -10057,10 +11020,12 @@ async function main() {
10057
11020
  console.log(' remits-cli tool --name <toolName> [--base-url URL] [--branch BRANCH] [--input \"{...}\"|--input-file file.json] [--data-mode test|prod] [--scope self|children|hierarchy] [--target-account-id ID] [--account-ids 1,2,3] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
10058
11021
  console.log(' remits-cli tool status --call-id <callId> [--base-url URL] [--account-id ID] [--data-mode test|prod]');
10059
11022
  console.log(' remits-cli workspace [show|use <name>|use --auto|clear] # isolate staging when several agents share a repo');
10060
- console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--changed-only] [--data-mode test|prod] [--json|--verbose]');
11023
+ console.log(' remits-cli verify start --summary "..." [--manifest file.json] # start a verification envelope');
11024
+ console.log(' remits-cli verify [current|use|clear|status|report|attach|test|token|stage|sync|tool]');
11025
+ console.log(' remits-cli components stage [--workset|--changed-only] [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--data-mode test|prod] [--json|--verbose]');
10061
11026
  console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--component-type TYPE --component-id ID] [--json|--verbose]');
10062
11027
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
10063
- console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
11028
+ console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
10064
11029
  console.log(' remits-cli components commit [--message \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
10065
11030
  console.log(' remits-cli components promotion [<branch>] [--json] [--no-fail] # promotion readiness + ordered next steps');
10066
11031
  console.log(' remits-cli components branches [--json] # committed branch variants for this account');
@@ -10138,6 +11103,11 @@ async function main() {
10138
11103
  process.exit(0);
10139
11104
  }
10140
11105
 
11106
+ if (wantsHelp && command === 'verify') {
11107
+ printVerifyHelp();
11108
+ process.exit(0);
11109
+ }
11110
+
10141
11111
  if (command === 'auth') {
10142
11112
  await authCommand(args);
10143
11113
  return;
@@ -10257,6 +11227,11 @@ async function main() {
10257
11227
  return;
10258
11228
  }
10259
11229
 
11230
+ if (command === 'verify') {
11231
+ await verifyCommand(args, subcommand);
11232
+ return;
11233
+ }
11234
+
10260
11235
  if (command === 'test' && subcommand === 'run') {
10261
11236
  await testCommand(args);
10262
11237
  return;