@remits/remits-cli 0.1.124 → 0.1.125

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
@@ -84,18 +84,29 @@ const ACCOUNT_SCAN_EXCLUDE_DIRS = new Set([
84
84
  // flag keeps last-wins so existing callers are unaffected.
85
85
  const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved', 'names']);
86
86
 
87
+ // `-m "msg"` is how git spells a commit message, and agents type it from habit. Before this the parser took a
88
+ // single-dash token as the VALUE of whatever flag preceded it: `--json -m "x"` silently turned JSON mode
89
+ // off, and `--summary -m "x"` committed with the default message. A single-letter `-x` token is now always
90
+ // a flag, never a value, and these aliases map it to its long name. Values like `-5` or `-abc` are untouched.
91
+ const SHORT_FLAG_ALIASES = { m: 'message', h: 'help' };
92
+
93
+ function isFlagToken(token) {
94
+ return typeof token === 'string' && (token.startsWith('--') || /^-[a-zA-Z]$/.test(token));
95
+ }
96
+
87
97
  function parseArgs(argv) {
88
98
  const out = { _: [] };
89
99
  for (let i = 0; i < argv.length; i++) {
90
100
  const arg = argv[i];
91
- if (!arg.startsWith('--')) {
101
+ if (!isFlagToken(arg)) {
92
102
  out._.push(arg);
93
103
  continue;
94
104
  }
95
- const key = arg.slice(2);
105
+ const shortName = arg.startsWith('--') ? null : arg.slice(1);
106
+ const key = shortName ? (SHORT_FLAG_ALIASES[shortName] || shortName) : arg.slice(2);
96
107
  const next = argv[i + 1];
97
108
  let value;
98
- if (!next || next.startsWith('--')) {
109
+ if (!next || isFlagToken(next)) {
99
110
  value = true;
100
111
  } else {
101
112
  value = next;
@@ -114,6 +125,25 @@ function flagEnabled(value) {
114
125
  return value === true || value === 'true' || value === '1' || value === 'yes';
115
126
  }
116
127
 
128
+ function withStdoutRoutedToStderr(enabled, fn) {
129
+ if (!enabled) return fn();
130
+ const originalLog = console.log;
131
+ console.log = (...args) => console.error(...args);
132
+ try {
133
+ const result = fn();
134
+ if (result && typeof result.then === 'function') {
135
+ return result.finally(() => {
136
+ console.log = originalLog;
137
+ });
138
+ }
139
+ console.log = originalLog;
140
+ return result;
141
+ } catch (err) {
142
+ console.log = originalLog;
143
+ throw err;
144
+ }
145
+ }
146
+
117
147
  function ensureSessionDir() {
118
148
  if (!fs.existsSync(SESSION_DIR)) {
119
149
  fs.mkdirSync(SESSION_DIR, { recursive: true });
@@ -453,11 +483,12 @@ function isLocalBaseUrl(baseUrl) {
453
483
  * boundaries by assumption, so the banner is loud, states the account it resolved, and distinguishes
454
484
  * live production writes from isolated test-data writes.
455
485
  */
456
- function printProdDataBanner({ dataMode, accountId, baseUrl, operation, mutating = false, dryRun = false }) {
486
+ function printProdDataBanner({ dataMode, accountId, baseUrl, operation, mutating = false, dryRun = false, stderr = false }) {
457
487
  const normalizedDataMode = String(dataMode || '').toLowerCase();
458
488
  const prodData = normalizedDataMode === 'prod';
459
489
  const prodHost = baseUrl && !isLocalBaseUrl(baseUrl);
460
490
  if (!prodData && !prodHost && !mutating) return;
491
+ const emit = stderr ? console.error : console.log;
461
492
  const bar = '='.repeat(72);
462
493
  let headline;
463
494
  if (prodData) {
@@ -473,13 +504,13 @@ function printProdDataBanner({ dataMode, accountId, baseUrl, operation, mutating
473
504
  ? 'TEST DATA — DRY RUN (no write will be attempted)'
474
505
  : 'TEST DATA WRITE — isolated test data';
475
506
  }
476
- console.log(bar);
477
- console.log(' ' + headline);
478
- console.log(' operation: ' + (operation || 'unknown'));
479
- if (accountId != null) console.log(' account: ' + accountId);
480
- if (baseUrl) console.log(' host: ' + normalizeBaseUrl(baseUrl));
481
- console.log(' dataMode: ' + (normalizedDataMode || 'unknown'));
482
- console.log(bar);
507
+ emit(bar);
508
+ emit(' ' + headline);
509
+ emit(' operation: ' + (operation || 'unknown'));
510
+ if (accountId != null) emit(' account: ' + accountId);
511
+ if (baseUrl) emit(' host: ' + normalizeBaseUrl(baseUrl));
512
+ emit(' dataMode: ' + (normalizedDataMode || 'unknown'));
513
+ emit(bar);
483
514
  }
484
515
 
485
516
  // A tool's OWN verdict, which is separate from whether the call was dispatched. Only an explicit
@@ -1493,18 +1524,18 @@ function maybePullPlatformRepo(dir, options) {
1493
1524
  const dirty = execSync('git -C ' + shellQuote(dir) + ' status --porcelain', { stdio: ['ignore', 'pipe', 'ignore'] })
1494
1525
  .toString().trim();
1495
1526
  if (dirty) {
1496
- console.log('[platform] Skipping git pull: ' + dir + ' has uncommitted changes.');
1527
+ console.error('[platform] Skipping git pull: ' + dir + ' has uncommitted changes.');
1497
1528
  return;
1498
1529
  }
1499
1530
  const originUrl = readGitRemoteUrl(dir, 'origin');
1500
1531
  if (originUrl && originUrl !== PLATFORM_REPO_GIT_URL) {
1501
- console.log('[platform] Found Remits platform repo at ' + dir + ', but its origin remote is ' + originUrl + ' (expected ' + PLATFORM_REPO_GIT_URL + ').');
1502
- console.log('[platform] Fix it with: git -C ' + dir + ' remote set-url origin ' + PLATFORM_REPO_GIT_URL);
1532
+ console.error('[platform] Found Remits platform repo at ' + dir + ', but its origin remote is ' + originUrl + ' (expected ' + PLATFORM_REPO_GIT_URL + ').');
1533
+ console.error('[platform] Fix it with: git -C ' + dir + ' remote set-url origin ' + PLATFORM_REPO_GIT_URL);
1503
1534
  }
1504
- console.log('[platform] Updating Remits platform repo (git pull)...');
1535
+ console.error('[platform] Updating Remits platform repo (git pull)...');
1505
1536
  execSync('git -C ' + shellQuote(dir) + ' pull --ff-only', { stdio: 'inherit' });
1506
1537
  } catch (err) {
1507
- console.log('[platform] git pull did not complete cleanly (leaving the repo as-is): ' + (err && err.message ? err.message : String(err)));
1538
+ console.error('[platform] git pull did not complete cleanly (leaving the repo as-is): ' + (err && err.message ? err.message : String(err)));
1508
1539
  }
1509
1540
  }
1510
1541
 
@@ -1556,21 +1587,21 @@ function ensurePlatformRepoLocal(options = {}) {
1556
1587
 
1557
1588
  const target = defaultPlatformRepoDir();
1558
1589
  if (fs.existsSync(target)) {
1559
- console.log('[platform] Skipping clone: ' + target + ' already exists but is not the Remits platform repo. Set REMITS_PLATFORM_DIR to its location.');
1590
+ console.error('[platform] Skipping clone: ' + target + ' already exists but is not the Remits platform repo. Set REMITS_PLATFORM_DIR to its location.');
1560
1591
  return null;
1561
1592
  }
1562
1593
  try {
1563
- console.log('[platform] Cloning Remits platform repo into ' + target + ' (one-time)...');
1594
+ console.error('[platform] Cloning Remits platform repo into ' + target + ' (one-time)...');
1564
1595
  ensureDir(path.dirname(target));
1565
1596
  execSync('git clone ' + shellQuote(PLATFORM_REPO_GIT_URL) + ' ' + shellQuote(target), { stdio: 'inherit' });
1566
1597
  } catch (err) {
1567
- console.log('[platform] Could not clone the Remits platform repo automatically: ' + (err && err.message ? err.message : String(err)));
1568
- console.log('[platform] Clone it manually with: git clone ' + PLATFORM_REPO_GIT_URL + ' ' + target);
1598
+ console.error('[platform] Could not clone the Remits platform repo automatically: ' + (err && err.message ? err.message : String(err)));
1599
+ console.error('[platform] Clone it manually with: git clone ' + PLATFORM_REPO_GIT_URL + ' ' + target);
1569
1600
  return null;
1570
1601
  }
1571
1602
  if (looksLikePlatformRepo(target)) {
1572
1603
  setPlatformRepoEntry(target, 'cloned');
1573
- console.log('[platform] Remits platform repo available at ' + target);
1604
+ console.error('[platform] Remits platform repo available at ' + target);
1574
1605
  return target;
1575
1606
  }
1576
1607
  return null;
@@ -1936,6 +1967,27 @@ function collectComponents(cwd) {
1936
1967
  components.push(component);
1937
1968
  }
1938
1969
 
1970
+ const rootReadme = path.join(cwd, 'README.md');
1971
+ if (fs.existsSync(rootReadme) && fs.statSync(rootReadme).isFile()) {
1972
+ const component = withFileKey({
1973
+ type: 'prompt',
1974
+ id: null,
1975
+ name: 'README',
1976
+ purpose: 'README',
1977
+ category: 'default',
1978
+ prompt: fs.readFileSync(rootReadme, 'utf8'),
1979
+ metadataAuthoritative: true
1980
+ }, 'prompt:name:readme');
1981
+ const fingerprint = {};
1982
+ Object.keys(component).sort().forEach((key) => {
1983
+ if (key !== 'hash') {
1984
+ fingerprint[key] = component[key] == null ? null : component[key];
1985
+ }
1986
+ });
1987
+ component.hash = sha256(stableStringify(fingerprint));
1988
+ components.push(component);
1989
+ }
1990
+
1939
1991
  return components;
1940
1992
  }
1941
1993
 
@@ -1954,6 +2006,16 @@ function componentPathInfo(cwd, filePath) {
1954
2006
  tests: 'test'
1955
2007
  };
1956
2008
  const relative = String(filePath || '').replace(/\\/g, '/').replace(/^"|"$/g, '');
2009
+ if (relative === 'README.md') {
2010
+ return {
2011
+ key: 'prompt:name:readme',
2012
+ type: 'prompt',
2013
+ id: null,
2014
+ name: 'README',
2015
+ field: 'prompt',
2016
+ path: relative
2017
+ };
2018
+ }
1957
2019
  const parts = relative.split('/');
1958
2020
  if (parts.length < 3 || parts[0] !== 'components') {
1959
2021
  return null;
@@ -2001,7 +2063,7 @@ function changedComponentsFromWorkingTree(cwd) {
2001
2063
  // status field on the FIRST line (` M path` -> `M path`). Any fixed-offset slice then reads the
2002
2064
  // path one character short and silently drops that entry — so a single modified component
2003
2065
  // reported as zero, while an untracked one ("?? path", no leading space) reported fine.
2004
- porcelain = execSync('git status --porcelain -- components', {
2066
+ porcelain = execSync('git status --porcelain -- components README.md', {
2005
2067
  cwd,
2006
2068
  stdio: ['ignore', 'pipe', 'pipe']
2007
2069
  }).toString();
@@ -2070,7 +2132,7 @@ function changedComponentsFromWorkingTree(cwd) {
2070
2132
  function changedComponentsSinceRef(cwd, ref) {
2071
2133
  let output = '';
2072
2134
  try {
2073
- output = execSync('git diff --name-only ' + JSON.stringify(ref) + '...HEAD -- components', {
2135
+ output = execSync('git diff --name-only ' + JSON.stringify(ref) + '...HEAD -- components README.md', {
2074
2136
  cwd,
2075
2137
  stdio: ['ignore', 'pipe', 'pipe']
2076
2138
  }).toString();
@@ -2809,16 +2871,63 @@ async function pushComponentsCommand(flags) {
2809
2871
  // Deliberately NOT an error, and deliberately not a clear. An agent that has not edited anything yet
2810
2872
  // is in an ordinary state; failing its loop teaches it nothing, and reconciling the lane to an empty
2811
2873
  // manifest would delete the overlay its next test run depends on.
2812
- console.log('Stage mode:', stageMode);
2813
- console.log('Workset detected from git: 0 component(s) — nothing was staged and the lane was left as it is.');
2814
- if (worksetReplace) {
2815
- console.log(' The lane was NOT cleared. Clearing stays explicit: `remits-cli components clear --all`,');
2816
- console.log(' or re-run with `--empty-workset clear` if an empty lane is what you meant.');
2874
+ const emptyResponse = {
2875
+ success: true,
2876
+ mode,
2877
+ dataMode,
2878
+ accountId,
2879
+ branchName,
2880
+ workspace,
2881
+ stagingLane: workspace ? branchName + ' [ws:' + workspace + ']' : branchName,
2882
+ sharedLane: !workspace,
2883
+ updated: 0,
2884
+ unchanged: 0,
2885
+ skipped: [],
2886
+ changedOnly: true,
2887
+ requestedStageMode: stageMode,
2888
+ stage: {
2889
+ mode: stageMode,
2890
+ manifestScope: 'working-tree',
2891
+ replacedLane: false,
2892
+ submittedCount: 0,
2893
+ worksetCount: 0,
2894
+ worksetKnown: true,
2895
+ retainedCount: null,
2896
+ removedRetainedCount: 0,
2897
+ unrepresentable: unstageable
2898
+ },
2899
+ laneSummary: {
2900
+ stagedCount: null,
2901
+ stageMode: 'unchanged',
2902
+ manifestScope: 'working-tree',
2903
+ submittedCount: 0,
2904
+ retainedCount: null,
2905
+ worksetCount: 0,
2906
+ worksetKnown: true,
2907
+ fullSnapshot: false
2908
+ },
2909
+ staged: false,
2910
+ message: 'Workset detected from git: 0 component(s); nothing was staged and the lane was left as it is.',
2911
+ hint: worksetReplace
2912
+ ? 'The lane was NOT cleared. Clearing stays explicit: remits-cli components clear --all, or re-run with --empty-workset clear if an empty lane is what you meant.'
2913
+ : null,
2914
+ changedFromWorkingTree: [],
2915
+ changedFromWorkingTreeAvailable: true,
2916
+ unrepresentableChanges: unstageable
2917
+ };
2918
+ if (flagEnabled(flags.json)) {
2919
+ console.log(JSON.stringify(emptyResponse, null, 2));
2920
+ } else {
2921
+ console.log('Stage mode:', stageMode);
2922
+ console.log(emptyResponse.message);
2923
+ if (worksetReplace) {
2924
+ console.log(' The lane was NOT cleared. Clearing stays explicit: `remits-cli components clear --all`,');
2925
+ console.log(' or re-run with `--empty-workset clear` if an empty lane is what you meant.');
2926
+ }
2927
+ printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
2928
+ printUnrepresentableChanges(unstageable);
2817
2929
  }
2818
- printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
2819
- printUnrepresentableChanges(unstageable);
2820
- return { success: true, mode, updated: 0, unchanged: 0, skipped: [], changedOnly: true, stageMode,
2821
- worksetCount: 0, staged: false };
2930
+ return emptyResponse;
2822
2931
  }
2823
2932
  }
2824
2933
 
@@ -2859,6 +2968,9 @@ async function pushComponentsCommand(flags) {
2859
2968
  // The git evidence, per component: which files changed and with what status. It is what lets the
2860
2969
  // console split a lane's "active workset" from the entries a previous full snapshot left behind.
2861
2970
  changedSet: (changedFromWorkingTree || []).concat(unstageable),
2971
+ // The commit this tree is on. Stamped onto every entry as `stageBaseSha`, so `components status` can
2972
+ // tell this lane — and anyone reading it — that somebody has landed since it was staged.
2973
+ baseSha: safeGitValue(cwd, 'git rev-parse HEAD') || undefined,
2862
2974
  // Kept for a platform that predates stageMode. Same meaning it always had.
2863
2975
  replace: !changedOnly,
2864
2976
  components
@@ -3023,6 +3135,25 @@ function unrepresentableChanges(changedFromWorkingTree, collected) {
3023
3135
  return report;
3024
3136
  }
3025
3137
 
3138
+ // One list from two sources, de-duplicated on identity + reason.
3139
+ function mergeUnrepresentable(local, platform) {
3140
+ const merged = [];
3141
+ const seen = new Set();
3142
+ [].concat(Array.isArray(local) ? local : [], Array.isArray(platform) ? platform : []).forEach((entry) => {
3143
+ if (!entry) return;
3144
+ const key = [
3145
+ String(entry.type || '').toLowerCase(),
3146
+ entry.id != null ? String(entry.id) : '',
3147
+ String(entry.name || '').toLowerCase(),
3148
+ entry.reason || ''
3149
+ ].join('|');
3150
+ if (seen.has(key)) return;
3151
+ seen.add(key);
3152
+ merged.push(entry);
3153
+ });
3154
+ return merged;
3155
+ }
3156
+
3026
3157
  function printUnrepresentableChanges(unstageable) {
3027
3158
  if (!unstageable || !unstageable.length) return;
3028
3159
  console.log('');
@@ -3299,7 +3430,9 @@ function printStageSummary(response, flags) {
3299
3430
  console.log(' Or drop the overlay entirely: remits-cli components clear --all');
3300
3431
  }
3301
3432
 
3302
- printUnrepresentableChanges(response.unrepresentableChanges || (stage.unrepresentable || []));
3433
+ // The CLI's own findings (deleted files) and the platform's (a README withheld on a variant branch) are
3434
+ // different sources. Preferring the first whenever it existed — even as an empty list — hid the second.
3435
+ printUnrepresentableChanges(mergeUnrepresentable(response.unrepresentableChanges, stage.unrepresentable));
3303
3436
  if (typeof printAccountLanes === 'function') {
3304
3437
  printAccountLanes(response);
3305
3438
  }
@@ -3325,6 +3458,8 @@ function printStatusSummary(response, flags) {
3325
3458
  printBranchContext(response);
3326
3459
  printStagingLane(response.branchName, response.workspace, null);
3327
3460
  printLaneSummary(response);
3461
+ printStagingFreshness(response.freshness);
3462
+ printRepositoryCheck(response.repositoryCheck);
3328
3463
  printComponentTypeCounts(response.entries || []);
3329
3464
 
3330
3465
  // Every lane staged on this branch, not just this one. An agent that believes it is working alone can
@@ -3457,7 +3592,7 @@ function printLaneSummary(response) {
3457
3592
  // lane is still reported authoritatively above.
3458
3593
  function printAccountLanes(response) {
3459
3594
  const lanes = Array.isArray(response.accountLanes) ? response.accountLanes : [];
3460
- const others = lanes.filter((lane) => !lane.mine);
3595
+ const others = lanes.filter((lane) => !isCurrentStagingLane(lane));
3461
3596
  if (!others.length) return;
3462
3597
 
3463
3598
  console.log('');
@@ -3477,7 +3612,7 @@ function printAccountLanes(response) {
3477
3612
  ? ', workset ' + lane.worksetCountAsOf : '';
3478
3613
  const stageMode = lane.stageMode && lane.stageMode !== 'unknown' ? ' [' + lane.stageMode + ']' : '';
3479
3614
  const shared = lane.sharedLane === true ? ' SHARED LANE' : '';
3480
- console.log(' ' + (lane.mine ? '* ' : ' ') + lane.branchName + ws
3615
+ console.log(' ' + (isCurrentStagingLane(lane) ? '* ' : ' ') + lane.branchName + ws
3481
3616
  + ' (' + world + ') — overlay ' + (lane.stagedCountAsOf || 0) + workset + stageMode + shared + who + ttl);
3482
3617
  });
3483
3618
  console.log(' (* = this command\'s lane)');
@@ -3485,6 +3620,13 @@ function printAccountLanes(response) {
3485
3620
  console.log(' from it writes overlays — never the trunk rows a trunk lane commits to.');
3486
3621
  }
3487
3622
 
3623
+ function isCurrentStagingLane(lane) {
3624
+ if (!lane) return false;
3625
+ if (typeof lane.currentLane === 'boolean') return lane.currentLane;
3626
+ // Older platforms used `mine` for "this exact command lane". Keep the terminal star compatible.
3627
+ return lane.mine === true;
3628
+ }
3629
+
3488
3630
  // Make the working tree's meaning explicit. An agent may be standing in a TRUNK checkout or in a
3489
3631
  // VARIANT branch's checkout, and the two differ on BOTH ends of the loop: what a run resolves, and
3490
3632
  // what a commit writes. Inferring that from the branch name alone is exactly the kind of ambiguity
@@ -3501,6 +3643,19 @@ function printBranchContext(response) {
3501
3643
  }
3502
3644
  console.log(' runs resolve: ' + ctx.resolves);
3503
3645
  console.log(' commit writes: ' + ctx.commitWrites);
3646
+ if (Object.prototype.hasOwnProperty.call(ctx, 'lastSyncedSha')) {
3647
+ // null is "unknown" (never synced, expired, or the last sync had errors) — never "nothing landed".
3648
+ console.log(' last platform sync of this branch: ' + (ctx.lastSyncedSha ? short(ctx.lastSyncedSha) : 'unknown'));
3649
+ }
3650
+ if (ctx.repository) {
3651
+ console.log(' sync reads repository: ' + ctx.repository +
3652
+ (ctx.commitOwnerAccountId != null ? ' (account ' + ctx.commitOwnerAccountId + ')' : ''));
3653
+ }
3654
+ if (ctx.inheritedSubscription) {
3655
+ console.log('');
3656
+ console.log(' INHERITED BRANCH: ' + (ctx.inheritedSubscriptionMessage ||
3657
+ ('this account resolves the branch through an ancestor owned by account ' + ctx.resolvingOwnerAccountId + '.')));
3658
+ }
3504
3659
 
3505
3660
  if (!ctx.onTrunk) {
3506
3661
  console.log(' variants stored on this branch: ' + (ctx.variantCount || 0));
@@ -3608,6 +3763,17 @@ async function statusComponentsCommand(flags) {
3608
3763
  throw new Error(response.message || 'Staging status failed');
3609
3764
  }
3610
3765
 
3766
+ // Has anybody landed since this lane was staged? Only this checkout can order commits, so the platform
3767
+ // supplies the facts (the last SHA it synced, each entry's base) and the comparison happens here.
3768
+ response.freshness = stagingFreshness({
3769
+ head: safeGitValue(cwd, 'git rev-parse HEAD'),
3770
+ lastSyncedSha: response.branchContext && response.branchContext.lastSyncedSha,
3771
+ entries: response.entries,
3772
+ laneSummary: response.laneSummary,
3773
+ isAncestor: (a, b) => gitIsAncestor(cwd, a, b)
3774
+ });
3775
+ response.repositoryCheck = repositoryCheck(cwd, response.branchContext);
3776
+
3611
3777
  await appendVerificationPacket(api, cwd, session, accountId, flags, {
3612
3778
  type: 'component_status',
3613
3779
  success: true,
@@ -3794,7 +3960,8 @@ async function syncComponentsCommand(rawFlags) {
3794
3960
  baseUrl,
3795
3961
  operation: 'components sync (branch ' + branchName + ')',
3796
3962
  mutating: true,
3797
- dryRun: dryRun || namesOnly
3963
+ dryRun: dryRun || namesOnly,
3964
+ stderr: flagEnabled(flags.json)
3798
3965
  });
3799
3966
 
3800
3967
  const syncPayload = {
@@ -3835,22 +4002,63 @@ async function syncComponentsCommand(rawFlags) {
3835
4002
  }).then((r) => r.data);
3836
4003
 
3837
4004
  const branchContext = statusResponse && statusResponse.branchContext;
3838
- if (!branchContext || branchContext.onTrunk) {
4005
+ // --safe is fail-closed: a sync that would reconcile a repository other than this checkout's is refused.
4006
+ const preflightRepoCheck = repositoryCheck(cwd, branchContext);
4007
+ if (preflightRepoCheck.matches === false) {
4008
+ const mismatch = repositoryMismatchMessage(preflightRepoCheck, 'components sync');
4009
+ if (flagEnabled(flags.json)) {
4010
+ const refusal = {
4011
+ success: false,
4012
+ mode: 'sync',
4013
+ dataMode,
4014
+ accountId,
4015
+ branchName,
4016
+ workspace,
4017
+ safe,
4018
+ branchContext,
4019
+ repositoryCheck: preflightRepoCheck,
4020
+ gateViolations: [mismatch],
4021
+ message: mismatch
4022
+ };
4023
+ console.log(JSON.stringify(refusal, null, 2));
4024
+ process.exitCode = 1;
4025
+ return refusal;
4026
+ }
4027
+ throw new Error(mismatch + ' Nothing was synced.');
4028
+ }
4029
+ if (branchContextIsTrunk(branchContext)) {
3839
4030
  // Trunk has no dry-run plan to gate on, and pretending otherwise would be worse than refusing:
3840
4031
  // an agent would read "safe" and get an ungated authoritative reconcile. Say what trunk sync is
3841
4032
  // and make the caller assert it.
3842
4033
  if (!safe) {
3843
4034
  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.');
3844
4035
  }
3845
- console.log('');
3846
- console.log('TRUNK SYNC — there is no dry-run plan for trunk, so --safe cannot gate this.');
3847
- console.log(' This is an AUTHORITATIVE full repo-to-DB reconcile of ' + (branchContext ? branchContext.trunkBranch || branchName : branchName) + ':');
3848
- console.log(' every component row is rewritten from the pushed repo, and any live component');
3849
- console.log(' missing from the repo is DELETED.');
3850
- console.log(' For a gated plan, work on a variant branch instead.');
4036
+ printTrunkSyncWarning(branchContext ? branchContext.trunkBranch || branchName : branchName, {
4037
+ stderr: flagEnabled(flags.json)
4038
+ });
3851
4039
  if (!flagEnabled(flags.yes)) {
3852
- throw new Error('components sync --safe on trunk requires --yes, because the plan cannot be previewed. ' +
3853
- 'Re-run with --yes to accept the full trunk reconcile, or switch to a variant branch for a gated sync.');
4040
+ const message = trunkSafeRequiresYesMessage('components sync');
4041
+ if (flagEnabled(flags.json)) {
4042
+ const refusal = {
4043
+ success: false,
4044
+ mode: 'sync',
4045
+ dataMode,
4046
+ accountId,
4047
+ branchName,
4048
+ workspace,
4049
+ dryRun,
4050
+ safe: true,
4051
+ branchContext,
4052
+ repositoryCheck: preflightRepoCheck,
4053
+ gates: { safeRequiresYes: false },
4054
+ gateViolations: [message],
4055
+ message
4056
+ };
4057
+ console.log(JSON.stringify(refusal, null, 2));
4058
+ process.exitCode = 1;
4059
+ return refusal;
4060
+ }
4061
+ throw new Error(message);
3854
4062
  }
3855
4063
  // Drop the gates that can only be evaluated against a plan. Leaving them on would fail the command
3856
4064
  // AFTER an authoritative reconcile had already been written, which is the worst of both.
@@ -3870,8 +4078,17 @@ async function syncComponentsCommand(rawFlags) {
3870
4078
  }
3871
4079
 
3872
4080
  preflightGate = evaluateSyncGates(previewResponse, gateFlags, changedFromWorkingTree, cwd);
4081
+ // Decorate the preview ONCE, before any branch below can print or return it, so every preview output
4082
+ // — names-only, a gate refusal, the full or --summary JSON, the commit outcome's nested sync — carries
4083
+ // the same gate results and repository check. Decorating per branch is how one branch was missed.
4084
+ decoratePreview(previewResponse, preflightGate, preflightRepoCheck);
3873
4085
 
3874
4086
  if (namesOnly) {
4087
+ if (flagEnabled(flags.json)) {
4088
+ console.log(JSON.stringify(previewResponse, null, 2));
4089
+ if (preflightGate.violations.length) process.exitCode = 1;
4090
+ return previewResponse;
4091
+ }
3875
4092
  printSessionResolutionWarning(sessionContext);
3876
4093
  printResolvedBaseUrl(baseUrl);
3877
4094
  console.log('Data mode:', previewResponse.dataMode || dataMode);
@@ -3882,6 +4099,12 @@ async function syncComponentsCommand(rawFlags) {
3882
4099
  return previewResponse;
3883
4100
  }
3884
4101
 
4102
+ if (preflightGate.violations.length && flagEnabled(flags.json)) {
4103
+ const previewSummary = previewSummaryFor(previewResponse, preflightGate);
4104
+ console.log(JSON.stringify(flagEnabled(flags.summary) ? previewSummary : previewResponse, null, 2));
4105
+ process.exitCode = 1;
4106
+ return previewResponse;
4107
+ }
3885
4108
  failOnSyncGate(preflightGate);
3886
4109
 
3887
4110
  // The plan passed every gate. Show it before mutating anyway: a gate proves the plan matches the
@@ -3903,9 +4126,17 @@ async function syncComponentsCommand(rawFlags) {
3903
4126
  throw new Error(response.message || 'Server sync failed');
3904
4127
  }
3905
4128
 
3906
- const summary = buildSyncSummary(response);
3907
4129
  const gate = preflightGate || evaluateSyncGates(response, gateFlags, changedFromWorkingTree, cwd);
4130
+ decorateSyncGate(response, gate);
4131
+ // A plain sync is not gated, but it must still SAY when it reconciled a repository other than this checkout's.
4132
+ response.repositoryCheck = repositoryCheck(cwd, { repository: response.sync && response.sync.repository });
4133
+ if (response.repositoryCheck.matches === false) {
4134
+ (flagEnabled(flags.json) ? console.error : console.log)('WARNING: ' +
4135
+ repositoryMismatchMessage(response.repositoryCheck, 'components sync'));
4136
+ }
4137
+ const summary = buildSyncSummary(response);
3908
4138
  summary.gates = gate.checks;
4139
+ summary.gateViolations = gate.violations;
3909
4140
 
3910
4141
  await appendVerificationPacket(api, cwd, session, accountId, flags, {
3911
4142
  type: response.sync && response.sync.dryRun ? 'sync_dry_run' : 'sync_mutation',
@@ -3926,7 +4157,7 @@ async function syncComponentsCommand(rawFlags) {
3926
4157
 
3927
4158
  if (flagEnabled(flags.json)) {
3928
4159
  console.log(JSON.stringify(flagEnabled(flags.summary) ? summary : response, null, 2));
3929
- failOnSyncGate(gate);
4160
+ if (gate.violations.length) process.exitCode = 1;
3930
4161
  return response;
3931
4162
  }
3932
4163
 
@@ -4082,7 +4313,8 @@ function resolveChangedSinceFlags(flags, cwd, branchContext, safe) {
4082
4313
  continue;
4083
4314
  }
4084
4315
  if (base) {
4085
- console.log('--safe: comparing the sync plan against everything this branch added since ' +
4316
+ const emit = flagEnabled(flags.json) ? console.error : console.log;
4317
+ emit('--safe: comparing the sync plan against everything this branch added since ' +
4086
4318
  candidate + ' (' + base.slice(0, 12) + ').');
4087
4319
  return Object.assign({}, flags, { 'changed-since': base });
4088
4320
  }
@@ -4122,7 +4354,8 @@ function syncPlanEntries(response) {
4122
4354
  bucket,
4123
4355
  type: String(entry.type || entry.kind || 'component').toLowerCase(),
4124
4356
  id: entry.id == null ? null : String(entry.id),
4125
- name: entry.name || null
4357
+ name: entry.name || null,
4358
+ path: entry.path || null
4126
4359
  });
4127
4360
  });
4128
4361
  });
@@ -4201,10 +4434,14 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
4201
4434
  checks.changedOnly = false;
4202
4435
  } else {
4203
4436
  const changedSet = (changedFromWorkingTree || []).concat(fromRef || []);
4204
- // A component the checkout edited is identified by type + id, or type + name for `new_` files.
4437
+ // A component the checkout edited is identified by type + id, type + name for `new_` files, or the
4438
+ // repo file it came from. The path is what matches the root README.md: the plan names the README
4439
+ // prompt after its TRUNK row (whatever that row is called), while the checkout only knows the file.
4205
4440
  const allowedIds = new Set(changedSet.filter((c) => c.id != null).map((c) => c.type + ':' + c.id));
4206
4441
  const allowedNames = new Set(changedSet.filter((c) => c.name).map((c) => c.type + ':' + String(c.name).toLowerCase()));
4442
+ const allowedPaths = new Set([].concat(...changedSet.map((c) => (Array.isArray(c.paths) ? c.paths : []))).map(String));
4207
4443
  const unexpected = entries.filter((entry) => {
4444
+ if (entry.path && allowedPaths.has(String(entry.path))) return false;
4208
4445
  if (entry.id != null && allowedIds.has(entry.type + ':' + entry.id)) return false;
4209
4446
  if (entry.name && allowedNames.has(entry.type + ':' + String(entry.name).toLowerCase())) return false;
4210
4447
  return true;
@@ -4242,11 +4479,234 @@ function failOnSyncGate(gate) {
4242
4479
  }
4243
4480
  }
4244
4481
 
4482
+ // The ONE decoration of a --safe preview (the server dry-run plan): gate results plus the repository check.
4483
+ function decoratePreview(previewResponse, gate, repoCheck) {
4484
+ decorateSyncGate(previewResponse, gate);
4485
+ if (previewResponse) previewResponse.repositoryCheck = repoCheck || null;
4486
+ return previewResponse;
4487
+ }
4488
+
4489
+ // The --summary shape of a decorated preview. buildSyncSummary reads `repositoryCheck` off the response, so
4490
+ // this must be built AFTER decoratePreview.
4491
+ function previewSummaryFor(previewResponse, gate) {
4492
+ const summary = buildSyncSummary(previewResponse);
4493
+ summary.gates = gate.checks;
4494
+ summary.gateViolations = gate.violations;
4495
+ return summary;
4496
+ }
4497
+
4498
+ function decorateSyncGate(response, gate) {
4499
+ if (!response || !gate) return response;
4500
+ response.gates = gate.checks || {};
4501
+ response.gateViolations = gate.violations || [];
4502
+ if (response.sync && typeof response.sync === 'object') {
4503
+ response.sync.gates = gate.checks || {};
4504
+ response.sync.gateViolations = gate.violations || [];
4505
+ }
4506
+ if (gate.violations && gate.violations.length) {
4507
+ response.success = false;
4508
+ response.message = 'components sync safety gate failed';
4509
+ }
4510
+ return response;
4511
+ }
4512
+
4513
+ function trunkSafeRequiresYesMessage(commandName) {
4514
+ return commandName + ' --safe on trunk requires --yes, because the plan cannot be previewed. ' +
4515
+ 'Re-run with --yes to accept the full trunk reconcile, or switch to a variant branch for a gated sync.';
4516
+ }
4517
+
4518
+ // ONE definition of "does this branch context mean trunk", shared by `components sync --safe` and the
4519
+ // pre-git `components commit` refusal. Fail closed: a missing context, or an `onTrunk` that is not
4520
+ // explicitly false, is the planless authoritative reconcile — never mistaken for a gated variant sync.
4521
+ function branchContextIsTrunk(branchContext) {
4522
+ return !branchContext || branchContext.onTrunk !== false;
4523
+ }
4524
+
4525
+ // The last path segment of a repository reference — "org/remits-x", "remits-x", or a git URL — lower-cased
4526
+ // and without ".git". Null when there is nothing to compare.
4527
+ function repositoryBaseName(value) {
4528
+ if (!value) return null;
4529
+ const text = String(value).trim().replace(/\/+$/, '').replace(/\.git$/i, '');
4530
+ const segment = text.split(/[/:]/).pop();
4531
+ return segment ? segment.toLowerCase() : null;
4532
+ }
4533
+
4534
+ // Does this checkout's origin name the repository the platform syncs for the command's account?
4535
+ // true / false, or null when either side is unknown — an unknown is never reported as a match OR a mismatch.
4536
+ function repositoryNamesMatch(platformRepository, originUrl) {
4537
+ const platform = repositoryBaseName(platformRepository);
4538
+ const origin = repositoryBaseName(originUrl);
4539
+ if (!platform || !origin) return null;
4540
+ return platform === origin;
4541
+ }
4542
+
4543
+ // A commit pushes to THIS checkout's origin; the platform then syncs the repository of the account the
4544
+ // command resolved (account-info.json, or --account-id). When those are different repositories the push is
4545
+ // never synced and the sync reconciles something nobody edited here — the one way a commit lands work
4546
+ // somewhere other than where it was made.
4547
+ function repositoryCheck(cwd, branchContext) {
4548
+ const platformRepository = branchContext && branchContext.repository ? String(branchContext.repository) : null;
4549
+ const checkoutOrigin = safeGitValue(cwd, 'git remote get-url origin') || null;
4550
+ return { platformRepository, checkoutOrigin, matches: repositoryNamesMatch(platformRepository, checkoutOrigin) };
4551
+ }
4552
+
4553
+ function repositoryMismatchMessage(check, commandName) {
4554
+ return commandName + ': this checkout\'s origin is ' + check.checkoutOrigin + ', but the platform syncs repository "' +
4555
+ check.platformRepository + '" for this account. A push from here would never be synced, and the sync would ' +
4556
+ 'reconcile a repository you are not editing. Run it from that repository\'s checkout, or check which account ' +
4557
+ 'this command resolved (account-info.json, --account-id).';
4558
+ }
4559
+
4560
+ function printRepositoryCheck(check) {
4561
+ if (!check || check.matches !== false) return;
4562
+ console.log('');
4563
+ console.log('REPOSITORY MISMATCH: this checkout\'s origin (' + check.checkoutOrigin + ') is not the repository the ' +
4564
+ 'platform syncs for this account (' + check.platformRepository + ').');
4565
+ console.log(' `components commit` and `components sync --safe` refuse here; a plain sync would reconcile ' +
4566
+ check.platformRepository + ', not what you edited.');
4567
+ }
4568
+
4569
+ // Every OTHER worktree that has `branch` checked out, from `git worktree list --porcelain`. Worktrees of one
4570
+ // branch SHARE its ref: a pull or a commit in any of them moves HEAD under the others, whose working trees then
4571
+ // no longer match it — and `components commit` (`git add -A`) from one of those records its stale tree over
4572
+ // work that has already landed. Reproduced in live verification: a sibling's trunk commit was silently reverted.
4573
+ // Pure, so it can be tested without a repository.
4574
+ function otherWorktreesOnBranch(porcelain, branch, currentTopLevel) {
4575
+ if (!porcelain || !branch) return [];
4576
+ const here = currentTopLevel ? path.resolve(String(currentTopLevel)) : null;
4577
+ return String(porcelain).split(/\n\s*\n/).map((block) => {
4578
+ const lines = block.split('\n');
4579
+ const worktree = (lines.find((l) => l.startsWith('worktree ')) || '').slice('worktree '.length).trim();
4580
+ const ref = (lines.find((l) => l.startsWith('branch ')) || '').slice('branch '.length).trim();
4581
+ return { worktree, ref };
4582
+ }).filter((w) => w.worktree && w.ref === 'refs/heads/' + branch && path.resolve(w.worktree) !== here)
4583
+ .map((w) => w.worktree);
4584
+ }
4585
+
4586
+ function sharedBranchMessage(branch, others) {
4587
+ return 'components commit: branch "' + branch + '" is also checked out in ' + others.join(', ') + '. Worktrees ' +
4588
+ 'of one branch share its ref: a pull or a commit in any of them moves HEAD under the others, and this ' +
4589
+ 'commit\'s `git add -A` would record a stale tree over work that has already landed. Give each agent its own ' +
4590
+ 'CLONE (git clone <repo> && remits-cli workspace use --auto), or pass --allow-shared-branch once you have ' +
4591
+ 'confirmed `git status` shows only your own changes against the current HEAD.';
4592
+ }
4593
+
4594
+ function trunkCommitRequiresYesMessage() {
4595
+ return 'components commit on trunk requires --yes. It runs `git add -A`, commits, pushes, and then an ' +
4596
+ 'AUTHORITATIVE full repo-to-DB reconcile that creates, updates, renames and DELETES live component rows, ' +
4597
+ 'and trunk has no dry-run plan to preview. Re-run with --yes to accept that, or use the observable ' +
4598
+ 'sequence: git commit, git push, `remits-cli components sync --safe --yes`, git pull --ff-only.';
4599
+ }
4600
+
4601
+ // Is `ancestor` contained in `descendant`? true / false, or null when git cannot say — typically an object
4602
+ // this checkout has never fetched, which is itself a statement that the checkout does not have it.
4603
+ function gitIsAncestor(cwd, ancestor, descendant) {
4604
+ if (!ancestor || !descendant) return null;
4605
+ try {
4606
+ execSync('git merge-base --is-ancestor ' + shellQuote(ancestor) + ' ' + shellQuote(descendant), {
4607
+ cwd,
4608
+ stdio: ['ignore', 'ignore', 'ignore']
4609
+ });
4610
+ return true;
4611
+ } catch (err) {
4612
+ return err && err.status === 1 ? false : null;
4613
+ }
4614
+ }
4615
+
4616
+ /**
4617
+ * Is this lane still describing the world the platform is in?
4618
+ *
4619
+ * After a sibling agent lands on the same branch, the platform clears only THAT agent's lane. Every other
4620
+ * lane keeps shadowing the newly committed rows with content staged from the old base, and nothing used to
4621
+ * say so. The platform reports the last commit it synced (`branchContext.lastSyncedSha`) and each entry's
4622
+ * `stageBaseSha`; only the local checkout can order commits, so the comparison happens here. Pure — git is
4623
+ * injected as `isAncestor(a, b)` — so it can be tested without a repository.
4624
+ */
4625
+ function stagingFreshness({ head, lastSyncedSha, entries, laneSummary, isAncestor }) {
4626
+ const rows = Array.isArray(entries) ? entries : [];
4627
+ const staged = rows.filter((entry) => entry && entry.stageBaseSha);
4628
+ const memo = new Map();
4629
+ const ancestorOf = (a, b) => {
4630
+ const key = a + '..' + b;
4631
+ if (!memo.has(key)) memo.set(key, isAncestor(a, b));
4632
+ return memo.get(key);
4633
+ };
4634
+
4635
+ // The platform synced a commit this checkout does not contain: somebody landed after you last pulled.
4636
+ let landedSinceHead = null;
4637
+ if (lastSyncedSha && head) {
4638
+ landedSinceHead = lastSyncedSha === head ? false : ancestorOf(lastSyncedSha, head) !== true;
4639
+ }
4640
+ // Staged from a commit strictly behind the last sync: these shadow rows that landed after they were staged.
4641
+ const behindLastSync = lastSyncedSha
4642
+ ? staged.filter((entry) => entry.stageBaseSha !== lastSyncedSha && ancestorOf(entry.stageBaseSha, lastSyncedSha) === true)
4643
+ : [];
4644
+ const fromOtherCommits = head ? staged.filter((entry) => entry.stageBaseSha !== head) : [];
4645
+ const baseShas = laneSummary && Array.isArray(laneSummary.baseShas)
4646
+ ? laneSummary.baseShas
4647
+ : Array.from(new Set(staged.map((entry) => entry.stageBaseSha)));
4648
+
4649
+ return {
4650
+ head: head || null,
4651
+ lastSyncedSha: lastSyncedSha || null,
4652
+ landedSinceHead,
4653
+ entriesBehindLastSync: behindLastSync.length,
4654
+ entriesFromOtherCommits: fromOtherCommits.length,
4655
+ // Staged by a build that predates the stamp. Unknown, never "fresh".
4656
+ entriesWithUnknownBase: rows.length - staged.length,
4657
+ baseShas,
4658
+ stale: landedSinceHead === true || behindLastSync.length > 0
4659
+ };
4660
+ }
4661
+
4662
+ function printStagingFreshness(freshness) {
4663
+ if (!freshness) return;
4664
+ const plural = (n) => n + ' staged entr' + (n === 1 ? 'y' : 'ies');
4665
+ if (freshness.landedSinceHead === true) {
4666
+ console.log('');
4667
+ console.log('LANDED SINCE YOUR BASE: the platform last synced ' + short(freshness.lastSyncedSha) +
4668
+ ' on this branch, and this checkout does not contain it.');
4669
+ console.log(' Somebody has landed since you pulled. Their rows are live, your lane still shadows them, and your');
4670
+ console.log(' next commit has to include their work. Run: git fetch origin && git pull --ff-only, then re-stage.');
4671
+ }
4672
+ if (freshness.entriesBehindLastSync > 0) {
4673
+ console.log('');
4674
+ console.log('STALE OVERLAY: ' + plural(freshness.entriesBehindLastSync) + ' came from a commit older than the last sync (' +
4675
+ short(freshness.lastSyncedSha) + ').');
4676
+ console.log(' They shadow committed rows that landed after they were staged. Re-stage from your current tree');
4677
+ console.log(' (remits-cli components stage --workset) or drop them (remits-cli components clear).');
4678
+ } else if (freshness.entriesFromOtherCommits > 0) {
4679
+ console.log('Note: ' + plural(freshness.entriesFromOtherCommits) + ' were staged from a commit other than your HEAD (' +
4680
+ short(freshness.head) + ').');
4681
+ }
4682
+ if (freshness.entriesWithUnknownBase > 0) {
4683
+ console.log('Note: ' + plural(freshness.entriesWithUnknownBase) + ' predate base tracking; their base commit is unknown.');
4684
+ }
4685
+ }
4686
+
4687
+ function printTrunkSyncWarning(trunkBranch, options) {
4688
+ const emit = options && options.stderr ? console.error : console.log;
4689
+ emit('');
4690
+ emit('TRUNK SYNC — there is no dry-run plan for trunk, so --safe cannot gate this.');
4691
+ emit(' This is an AUTHORITATIVE full repo-to-DB reconcile of ' + (trunkBranch || 'main') + ':');
4692
+ emit(' every component row is rewritten from the pushed repo, and any live component');
4693
+ emit(' missing from the repo is DELETED.');
4694
+ emit(' For a gated plan, work on a variant branch instead.');
4695
+ }
4696
+
4245
4697
  function buildSyncSummary(response) {
4246
4698
  const sync = (response && response.sync) || {};
4247
4699
  const results = sync.syncResults || {};
4248
4700
  const errors = Array.isArray(results.errors) ? results.errors : [];
4249
4701
  const skipped = Array.isArray(results.skipped) ? results.skipped : [];
4702
+ // Which repository the platform read, and whether it is this checkout's. Carried on EVERY summary shape —
4703
+ // `--summary`, `--json --summary` and the commit outcome's nested sync all print this object, not the raw
4704
+ // response, so a check attached only to the response is invisible to exactly the callers that ask for less.
4705
+ const repositoryCheck = (response && response.repositoryCheck) || null;
4706
+ const repositoryWarning = repositoryCheck && repositoryCheck.matches === false
4707
+ ? 'REPOSITORY MISMATCH: the platform synced ' + repositoryCheck.platformRepository +
4708
+ ', but this checkout\'s origin is ' + repositoryCheck.checkoutOrigin
4709
+ : null;
4250
4710
 
4251
4711
  // A sync that SHORT-CIRCUITED on the cached branch SHA did no work at all, and the server says so
4252
4712
  // (`skipped: true` plus a message). Rendering it through the normal shape below printed
@@ -4267,14 +4727,18 @@ function buildSyncSummary(response) {
4267
4727
  skipped: true,
4268
4728
  reason: sync.message || 'branch head matches the cached sync SHA; the branch was not re-read',
4269
4729
  branchHeadSha: sync.branchHeadSha || sync.postSyncSha || null,
4270
- warnings: [
4730
+ repository: sync.repository || null,
4731
+ repositoryCheck,
4732
+ gates: (response && response.gates) || null,
4733
+ gateViolations: (response && response.gateViolations) || null,
4734
+ warnings: (repositoryWarning ? [repositoryWarning] : []).concat([
4271
4735
  // Scoped to COMPONENTS deliberately: the short-circuit still refreshes the delivered guides and
4272
4736
  // account metadata, so "nothing happened" would be its own small untruth.
4273
4737
  'NO COMPONENTS WERE SYNCED. This is the cached-SHA short-circuit, not an empty plan — the ' +
4274
4738
  'branch was never re-read.',
4275
4739
  'Use --dry-run to see the real plan (a dry run always re-reads the branch), or ' +
4276
4740
  '--force-tombstones to re-read and apply it.'
4277
- ]
4741
+ ])
4278
4742
  };
4279
4743
  }
4280
4744
 
@@ -4285,24 +4749,54 @@ function buildSyncSummary(response) {
4285
4749
  if ((sync.mode || 'trunk') === 'trunk') {
4286
4750
  const created = Array.isArray(results.created) ? results.created : [];
4287
4751
  const updated = Array.isArray(results.updated) ? results.updated : [];
4752
+ const unchanged = Array.isArray(results.unchanged) ? results.unchanged : [];
4288
4753
  const renamed = Array.isArray(results.renamed) ? results.renamed : [];
4289
4754
  const deleted = Array.isArray(results.deleted) ? results.deleted : [];
4290
4755
  const trunkWarnings = [];
4291
4756
  if (deleted.length) trunkWarnings.push(String(deleted.length) + ' live component(s) DELETED');
4292
4757
  if (errors.length) trunkWarnings.push(String(errors.length) + ' sync error' + (errors.length === 1 ? '' : 's') + ' present');
4758
+ if (repositoryWarning) trunkWarnings.push(repositoryWarning);
4293
4759
  return {
4294
4760
  success: response && response.success === true,
4295
4761
  accountId: response && response.accountId,
4296
4762
  branchName: sync.branchName || (response && response.branchName),
4297
4763
  mode: 'trunk',
4298
4764
  dryRun: false,
4765
+ repository: sync.repository || null,
4766
+ repositoryCheck,
4767
+ gates: (response && response.gates) || null,
4768
+ gateViolations: (response && response.gateViolations) || null,
4299
4769
  // Ids are the part that cannot be recovered later: a promotion mints them once, and anything that
4300
4770
  // recorded the old identity will not line up if they are re-minted.
4301
4771
  created: created.map((e) => ({ type: e.type, name: e.name, id: e.newId != null ? e.newId : e.id })),
4302
- updated: updated.length,
4303
- renamed: renamed.length,
4772
+ createdCount: created.length,
4773
+ updated: updated.map((e) => ({
4774
+ type: e.type,
4775
+ name: e.name,
4776
+ id: e.id,
4777
+ changed: Array.isArray(e.changed) ? e.changed : (e.content ? [e.content] : [])
4778
+ })),
4779
+ updatedCount: updated.length,
4780
+ unchanged: unchanged.length,
4781
+ renamed: renamed.map((e) => ({
4782
+ from: e.from || e.oldPath || e.oldName,
4783
+ to: e.to || e.newPath || e.newName,
4784
+ reason: e.reason || (e.message ? String(e.message) : undefined),
4785
+ type: e.type,
4786
+ name: e.name,
4787
+ id: e.id
4788
+ })),
4789
+ renamedCount: renamed.length,
4304
4790
  deleted: deleted.map((e) => ({ type: e.type, name: e.name, id: e.id })),
4305
- skipped: skipped.length,
4791
+ deletedCount: deleted.length,
4792
+ skipped: skipped.map((e) => ({
4793
+ type: e.type,
4794
+ name: e.name,
4795
+ id: e.id,
4796
+ reason: e.reason || (e.pruned ? 'pruned' : undefined),
4797
+ files: e.files
4798
+ })),
4799
+ skippedCount: skipped.length,
4306
4800
  errors: errors.length,
4307
4801
  errorDetails: errors.map((entry) => ({ type: entry.type, id: entry.id, error: entry.error })),
4308
4802
  warnings: trunkWarnings
@@ -4316,12 +4810,17 @@ function buildSyncSummary(response) {
4316
4810
  const warnings = [];
4317
4811
  if (removed.length) warnings.push(String(removed.length) + ' removed/tombstone entr' + (removed.length === 1 ? 'y' : 'ies') + ' present');
4318
4812
  if (errors.length) warnings.push(String(errors.length) + ' sync error' + (errors.length === 1 ? '' : 's') + ' present');
4813
+ if (repositoryWarning) warnings.push(repositoryWarning);
4319
4814
  return {
4320
4815
  success: response && response.success === true,
4321
4816
  accountId: response && response.accountId,
4322
4817
  branchName: sync.branchName || (response && response.branchName),
4323
4818
  mode: sync.mode || 'trunk',
4324
4819
  dryRun: Boolean(sync.dryRun),
4820
+ repository: sync.repository || null,
4821
+ repositoryCheck,
4822
+ gates: (response && response.gates) || null,
4823
+ gateViolations: (response && response.gateViolations) || null,
4325
4824
  overridden: overridden.length,
4326
4825
  added: added.length,
4327
4826
  removed: removed.map((entry) => ({
@@ -4332,7 +4831,15 @@ function buildSyncSummary(response) {
4332
4831
  variantId: entry.variantId
4333
4832
  })),
4334
4833
  unchanged: unchanged.length,
4335
- skipped: skipped.length,
4834
+ skipped: skipped.map((entry) => ({
4835
+ type: entry.type,
4836
+ kind: entry.kind,
4837
+ name: entry.name,
4838
+ id: entry.id,
4839
+ variantId: entry.variantId,
4840
+ reason: entry.reason
4841
+ })),
4842
+ skippedCount: skipped.length,
4336
4843
  errors: errors.length,
4337
4844
  errorDetails: errors.map((entry) => ({
4338
4845
  type: entry.type,
@@ -4420,11 +4927,32 @@ async function branchesComponentsCommand(flags) {
4420
4927
 
4421
4928
  function printBranchesSummary(response) {
4422
4929
  console.log('Trunk branch:', response.trunk || 'main');
4930
+ if (response.branchOwnerAccountId && response.branchOwnerAccountId !== response.accountId) {
4931
+ console.log('Branch owner account:', response.branchOwnerAccountId, '(caller account ' + response.accountId + ')');
4932
+ }
4423
4933
 
4424
4934
  if (response.mode === 'list') {
4425
4935
  const branches = response.branches || [];
4936
+ const subscription = response.subscription;
4937
+ if (subscription && subscription.branchName) {
4938
+ const inherited = subscription.subscriberAccountId != null && String(subscription.subscriberAccountId) !== String(response.accountId)
4939
+ ? ', inherited through account ' + subscription.subscriberAccountId : '';
4940
+ console.log('This account resolves branch "' + subscription.branchName + '" owned by account ' + subscription.ownerAccountId +
4941
+ (subscription.ownerAccountName ? ' (' + subscription.ownerAccountName + ')' : '') + inherited + '.');
4942
+ console.log(' Inspect it: remits-cli components branch ' + subscription.branchName);
4943
+ }
4944
+ // The subscriptions on this account's OWN edges. `subscription` is the resolved-path answer, which is null
4945
+ // for an account with several upward edges and no anchor — a subscriber then listed its own subscription
4946
+ // as nothing. The edges are the fact.
4947
+ const edgeSubscriptions = Array.isArray(response.branchSubscriptions) ? response.branchSubscriptions : [];
4948
+ if (!(subscription && subscription.branchName) && edgeSubscriptions.length) {
4949
+ edgeSubscriptions.forEach((e) => console.log('This account subscribes to branch "' + e.branchName +
4950
+ '" on its edge to account ' + e.parentAccountId + (e.parentAccountName ? ' (' + e.parentAccountName + ')' : '') +
4951
+ (e.primary ? ' [primary edge]' : '') + '.'));
4952
+ console.log(' Inspect it: remits-cli components branch ' + edgeSubscriptions[0].branchName);
4953
+ }
4426
4954
  if (!branches.length) {
4427
- console.log('No component variant branches for this account.');
4955
+ console.log('No component variant branches OWNED by this account.');
4428
4956
  return;
4429
4957
  }
4430
4958
  console.log('');
@@ -4685,9 +5213,14 @@ async function commitComponentsCommand(flags) {
4685
5213
  const commitMessage = String(flags.message || ('remits-cli commit sync ' + new Date().toISOString()));
4686
5214
  const allowEmpty = flags['allow-empty'] === true || flags['allow-empty'] === 'true';
4687
5215
  const skipGit = flags['skip-git'] === true || flags['skip-git'] === 'true';
5216
+ const jsonOutput = flagEnabled(flags.json);
5217
+ const emit = jsonOutput ? console.error : console.log;
4688
5218
  if (flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun)) {
4689
5219
  throw new Error('components commit does not support --dry-run because it performs local git writes. Use `remits-cli components sync --dry-run`.');
4690
5220
  }
5221
+ if (await refuseUnsafeTrunkCommitBeforeGit(flags, accountId, branchName, skipGit)) {
5222
+ return;
5223
+ }
4691
5224
 
4692
5225
  // Landing is serial and nothing can make it concurrent: `git add -A` sweeps a shared checkout, and the
4693
5226
  // platform pushes a regenerated `account-info.json` back to the branch during sync, so two commits
@@ -4699,14 +5232,33 @@ async function commitComponentsCommand(flags) {
4699
5232
  // The honest limit: this cannot stop a raw `git push` somebody runs themselves. It makes `remits-cli
4700
5233
  // components commit` serial, which is the path everything actually uses.
4701
5234
  const landing = await acquireLandingLease(flags, accountId, branchName);
5235
+ // What this commit has done so far, stated rather than left to be inferred from prose. "Pushed but not
5236
+ // synced" is the state the next agent most needs to know about — the remote and the platform disagree
5237
+ // until a sync succeeds, and the next sync of this branch by ANYONE lands it — and it used to be
5238
+ // recoverable only by reading terminal output. In --json mode this is the ONE document on stdout.
5239
+ const outcome = {
5240
+ success: false,
5241
+ mode: 'commit',
5242
+ accountId,
5243
+ branchName,
5244
+ phase: skipGit ? 'sync' : 'stage',
5245
+ skipGit,
5246
+ gitCommitted: false,
5247
+ pushed: false,
5248
+ pushedSha: null,
5249
+ synced: false,
5250
+ postSyncSha: null,
5251
+ pulled: false,
5252
+ sync: null
5253
+ };
4702
5254
  try {
4703
5255
  if (!skipGit) {
4704
- console.log('Phase 1/4: staged compile validation');
5256
+ emit('Phase 1/4: staged compile validation');
4705
5257
  // MERGE semantics on purpose (`--changed-only`, never `--workset`). A workset stage RECONCILES the
4706
5258
  // lane — it deletes every entry outside the git changed set — and the lane is frequently shared. A
4707
5259
  // validation pass must not be able to delete another agent's staged work as a side effect of somebody
4708
5260
  // running `components commit`. This adds the changed components to the lane and leaves the rest alone.
4709
- await pushComponentsCommand(Object.assign({}, flags, {
5261
+ await withStdoutRoutedToStderr(jsonOutput, () => pushComponentsCommand(Object.assign({}, flags, {
4710
5262
  branch: branchName,
4711
5263
  'account-id': accountId,
4712
5264
  mode: 'stage',
@@ -4714,24 +5266,26 @@ async function commitComponentsCommand(flags) {
4714
5266
  'replace-lane': false,
4715
5267
  replaceLane: false,
4716
5268
  'changed-only': true
4717
- }));
5269
+ })));
4718
5270
 
4719
- console.log('Phase 2/4: local git commit/push');
5271
+ outcome.phase = 'git';
5272
+ emit('Phase 2/4: local git commit/push');
4720
5273
  const status = runGit(cwd, 'git status --porcelain');
4721
5274
  if (status || allowEmpty) {
4722
5275
  runGit(cwd, 'git add -A');
4723
5276
  const commitCmd = 'git commit ' + (allowEmpty ? '--allow-empty ' : '') + '-m ' + shellQuote(commitMessage);
4724
5277
  try {
4725
5278
  runGit(cwd, commitCmd);
5279
+ outcome.gitCommitted = true;
4726
5280
  } catch (err) {
4727
5281
  if (!allowEmpty && String(err.message || '').toLowerCase().includes('nothing to commit')) {
4728
- console.log('No staged changes to commit.');
5282
+ emit('No staged changes to commit.');
4729
5283
  } else {
4730
5284
  throw err;
4731
5285
  }
4732
5286
  }
4733
5287
  } else {
4734
- console.log('No local changes detected; skipping local git commit.');
5288
+ emit('No local changes detected; skipping local git commit.');
4735
5289
  }
4736
5290
 
4737
5291
  try {
@@ -4739,25 +5293,46 @@ async function commitComponentsCommand(flags) {
4739
5293
  } catch (err) {
4740
5294
  runGit(cwd, 'git push --set-upstream origin ' + shellQuote(branchName));
4741
5295
  }
5296
+ outcome.pushed = true;
5297
+ outcome.pushedSha = safeGitValue(cwd, 'git rev-parse HEAD');
4742
5298
  } else {
4743
- console.log('Skipping local git phase. Prefer `remits-cli components sync` if you only need the server sync step.');
5299
+ emit('Skipping local git phase. Prefer `remits-cli components sync` if you only need the server sync step.');
4744
5300
  }
4745
5301
 
4746
- console.log('Phase 3/4: server sync');
5302
+ outcome.phase = 'sync';
5303
+ emit('Phase 3/4: server sync');
4747
5304
  if (flagEnabled(flags.safe)) {
4748
- // Compile validation already ran before the push. The sync safety gates still run after the push
4749
- // because they need the server's remote-branch plan.
4750
- console.log(' --safe: compile validation ran before git push; the sync plan gate below runs before');
4751
- console.log(' the platform writes trunk rows or ComponentVariant overlays.');
5305
+ if (skipGit) {
5306
+ emit(' --safe: running the sync plan gate before the platform writes trunk rows or ComponentVariant overlays.');
5307
+ } else {
5308
+ // Compile validation already ran before the push. The sync safety gates still run after the push
5309
+ // because they need the server's remote-branch plan.
5310
+ emit(' --safe: compile validation ran before git push; the sync plan gate below runs before');
5311
+ emit(' the platform writes trunk rows or ComponentVariant overlays.');
5312
+ }
4752
5313
  }
4753
- const syncResponse = await syncComponentsCommand({
5314
+ // In --json mode stdout carries ONE document — this command's outcome — so the sync's own document is
5315
+ // routed to stderr rather than printed ahead of it.
5316
+ const syncResponse = await withStdoutRoutedToStderr(jsonOutput, () => syncComponentsCommand({
4754
5317
  ...flags,
4755
5318
  branch: branchName,
4756
5319
  'account-id': accountId
4757
- });
5320
+ }));
5321
+ outcome.sync = syncResponse && flagEnabled(flags.summary) ? buildSyncSummary(syncResponse) : (syncResponse || null);
5322
+ // Stop here when the sync did not succeed. In --json mode a refused sync gate RETURNS (with a non-zero
5323
+ // exit code) instead of throwing, and this used to fall straight through into the pull phase.
5324
+ if (!syncResponse || syncResponse.success === false) {
5325
+ const reasons = syncResponse && Array.isArray(syncResponse.gateViolations) && syncResponse.gateViolations.length
5326
+ ? ': ' + syncResponse.gateViolations.join('; ')
5327
+ : '';
5328
+ throw new Error(((syncResponse && syncResponse.message) || 'components sync did not succeed') + reasons);
5329
+ }
5330
+ outcome.synced = true;
5331
+ outcome.postSyncSha = (syncResponse.sync && syncResponse.sync.postSyncSha) || null;
4758
5332
 
4759
5333
  if (!skipGit) {
4760
- console.log('Phase 4/4: local fast-forward pull');
5334
+ outcome.phase = 'pull';
5335
+ emit('Phase 4/4: local fast-forward pull');
4761
5336
  runGit(cwd, 'git fetch origin ' + shellQuote(branchName));
4762
5337
  const expectedSha = syncResponse && syncResponse.sync && syncResponse.sync.postSyncSha;
4763
5338
  if (expectedSha) {
@@ -4767,7 +5342,7 @@ async function commitComponentsCommand(flags) {
4767
5342
  'Remote branch head changed after sync. Expected ' + expectedSha + ' from remits sync, but origin/' + branchName + ' is ' + fetchedSha + '. Stop and investigate before pulling.'
4768
5343
  );
4769
5344
  }
4770
- console.log('Verified remote branch head:', expectedSha);
5345
+ emit('Verified remote branch head:', expectedSha);
4771
5346
  }
4772
5347
  runGit(cwd, 'git pull --ff-only origin ' + shellQuote(branchName));
4773
5348
  if (expectedSha) {
@@ -4777,9 +5352,23 @@ async function commitComponentsCommand(flags) {
4777
5352
  'Local HEAD does not match remits sync SHA after pull. Expected ' + expectedSha + ', got ' + localSha + '.'
4778
5353
  );
4779
5354
  }
4780
- console.log('Verified local HEAD matches remits sync SHA:', expectedSha);
5355
+ emit('Verified local HEAD matches remits sync SHA:', expectedSha);
4781
5356
  }
5357
+ outcome.pulled = true;
4782
5358
  }
5359
+ outcome.phase = 'complete';
5360
+ outcome.success = true;
5361
+ if (jsonOutput) console.log(JSON.stringify(outcome, null, 2));
5362
+ return outcome;
5363
+ } catch (err) {
5364
+ outcome.error = err && err.message ? err.message : String(err);
5365
+ outcome.nextStep = commitRecoveryHint(outcome);
5366
+ if (jsonOutput) {
5367
+ console.log(JSON.stringify(outcome, null, 2));
5368
+ process.exitCode = 1;
5369
+ return outcome;
5370
+ }
5371
+ throw new Error(outcome.error + (outcome.nextStep ? '\n' + outcome.nextStep : ''));
4783
5372
  } finally {
4784
5373
  // Give the branch back so the next agent lands in seconds rather than waiting out the TTL. Nothing
4785
5374
  // depends on this happening — the lease expires on its own, which is what covers the run that is
@@ -4788,6 +5377,137 @@ async function commitComponentsCommand(flags) {
4788
5377
  }
4789
5378
  }
4790
5379
 
5380
+ // What to do next, given how far a `components commit` got. The phase is the whole answer: a failure
5381
+ // before the push leaves the remote and the platform untouched; a failure AT the sync leaves them
5382
+ // disagreeing, which is the state the next agent must be told about in so many words.
5383
+ function commitRecoveryHint(outcome) {
5384
+ const o = outcome || {};
5385
+ const branch = o.branchName || '<branch>';
5386
+ const abbrev = (sha) => (sha ? String(sha).slice(0, 12) : null);
5387
+ switch (o.phase) {
5388
+ case 'stage':
5389
+ return 'Nothing was committed, pushed or synced. Fix the stage/compile error above and re-run.';
5390
+ case 'git':
5391
+ return o.gitCommitted
5392
+ ? 'A local commit was created but NOT pushed; the remote and the platform are unchanged. Resolve the push ' +
5393
+ '(usually `git pull --rebase origin ' + branch + '`), then re-run.'
5394
+ : 'Nothing was pushed or synced.';
5395
+ case 'sync':
5396
+ return o.pushed
5397
+ ? 'PUSHED BUT NOT SYNCED: ' + (abbrev(o.pushedSha) || 'the push') + ' is on origin/' + branch +
5398
+ ' but the platform has not reconciled it, so the remote and the platform now differ, and the next sync of ' +
5399
+ 'this branch by anyone will land it. Fix the cause above, then run `remits-cli components sync` ' +
5400
+ '(with --safe on a variant branch). Do not re-run `components commit`.'
5401
+ : 'Nothing was synced.';
5402
+ case 'pull':
5403
+ return 'The platform synced ' + (abbrev(o.postSyncSha) || 'the branch') + ', but this checkout was not ' +
5404
+ 'fast-forwarded. Run `git fetch origin && git pull --ff-only origin ' + branch + '` before any further git work.';
5405
+ default:
5406
+ return null;
5407
+ }
5408
+ }
5409
+
5410
+ // Every commit on trunk must be acknowledged with --yes, BEFORE any git write. Trunk has no dry-run plan,
5411
+ // so neither `--safe` nor anything else can preview what the authoritative reconcile will create, rename or
5412
+ // delete; the only honest gate is an explicit acknowledgement. It used to fire only under `--safe`, which
5413
+ // left the most dangerous command in the CLI ungated by default. `--skip-git` is included: it is the same
5414
+ // planless reconcile without the push.
5415
+ async function refuseUnsafeTrunkCommitBeforeGit(flags, accountId, branchName, skipGit) {
5416
+ const cwd = process.cwd();
5417
+ // Before ANY git write: is this branch also checked out in another worktree? See otherWorktreesOnBranch.
5418
+ if (!skipGit && !flagEnabled(flags['allow-shared-branch'])) {
5419
+ const others = otherWorktreesOnBranch(safeGitValue(cwd, 'git worktree list --porcelain'), branchName,
5420
+ safeGitValue(cwd, 'git rev-parse --show-toplevel'));
5421
+ if (others.length) {
5422
+ const shared = sharedBranchMessage(branchName, others);
5423
+ if (flagEnabled(flags.json)) {
5424
+ console.log(JSON.stringify({
5425
+ success: false,
5426
+ mode: 'commit',
5427
+ accountId,
5428
+ branchName,
5429
+ phase: 'pre-git',
5430
+ gitWritten: false,
5431
+ sharedBranchWorktrees: others,
5432
+ gateViolations: [shared],
5433
+ message: shared
5434
+ }, null, 2));
5435
+ process.exitCode = 1;
5436
+ return true;
5437
+ }
5438
+ throw new Error(shared + ' No git commit or push was attempted.');
5439
+ }
5440
+ }
5441
+ const { session } = resolveSessionContext(cwd, flags);
5442
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
5443
+ const dataMode = resolveDataMode(flags, session);
5444
+ const workspace = resolveWorkspace(cwd, flags);
5445
+ const api = buildAxios(baseUrl, session.token, 20000);
5446
+ const statusResponse = await loggedPost(api, cwd, '/cli/components', {
5447
+ token: session.token,
5448
+ accountId,
5449
+ branchName,
5450
+ workspace,
5451
+ dataMode,
5452
+ mode: 'status'
5453
+ }).then((r) => r.data);
5454
+ const branchContext = statusResponse && statusResponse.branchContext;
5455
+ // Before ANY git write: will the push reach the repository the platform syncs? Refused only on a definite
5456
+ // mismatch — no origin, or a platform that does not report its repository, is unknown and not refused.
5457
+ // `--skip-git` pushes nothing, so there is nothing to mis-deliver.
5458
+ const repoCheck = skipGit ? null : repositoryCheck(cwd, branchContext);
5459
+ if (repoCheck && repoCheck.matches === false) {
5460
+ const mismatch = repositoryMismatchMessage(repoCheck, 'components commit');
5461
+ if (flagEnabled(flags.json)) {
5462
+ console.log(JSON.stringify({
5463
+ success: false,
5464
+ mode: 'commit',
5465
+ dataMode,
5466
+ accountId,
5467
+ branchName,
5468
+ workspace,
5469
+ branchContext,
5470
+ repositoryCheck: repoCheck,
5471
+ phase: 'pre-git',
5472
+ gitWritten: false,
5473
+ gateViolations: [mismatch],
5474
+ message: mismatch
5475
+ }, null, 2));
5476
+ process.exitCode = 1;
5477
+ return true;
5478
+ }
5479
+ throw new Error(mismatch + ' No git commit or push was attempted.');
5480
+ }
5481
+ if (!flagEnabled(flags.yes) && branchContextIsTrunk(branchContext)) {
5482
+ printTrunkSyncWarning(branchContext ? branchContext.trunkBranch || branchName : branchName, {
5483
+ stderr: flagEnabled(flags.json)
5484
+ });
5485
+ const message = trunkCommitRequiresYesMessage();
5486
+ if (flagEnabled(flags.json)) {
5487
+ console.log(JSON.stringify({
5488
+ success: false,
5489
+ mode: 'commit',
5490
+ dataMode,
5491
+ accountId,
5492
+ branchName,
5493
+ workspace,
5494
+ safe: flagEnabled(flags.safe),
5495
+ skipGit: !!skipGit,
5496
+ branchContext,
5497
+ phase: 'pre-git',
5498
+ gitWritten: false,
5499
+ gates: { safeRequiresYes: false },
5500
+ gateViolations: [message],
5501
+ message
5502
+ }, null, 2));
5503
+ process.exitCode = 1;
5504
+ return true;
5505
+ }
5506
+ throw new Error(message + ' No git commit or push was attempted.');
5507
+ }
5508
+ return false;
5509
+ }
5510
+
4791
5511
  /**
4792
5512
  * Take the exclusive right to land this branch, or stop with the sentence saying who has it.
4793
5513
  *
@@ -4812,7 +5532,8 @@ async function acquireLandingLease(flags, accountId, branchName) {
4812
5532
  const response = await buildAxios(baseUrl, session.token, 20000)
4813
5533
  .post('/cli/components', payload).then((r) => r.data);
4814
5534
  if (response && response.success) {
4815
- console.log('Landing lease: held on ' + branchName + ' (' +
5535
+ const emit = flagEnabled(flags.json) ? console.error : console.log;
5536
+ emit('Landing lease: held on ' + branchName + ' (' +
4816
5537
  (response.ttlMinutes || '?') + 'm, renewed by the sync).');
4817
5538
  return { flags, accountId, branchName, baseUrl, session, held: true };
4818
5539
  }
@@ -4904,7 +5625,8 @@ async function waitForToolStatus(api, cwd, options) {
4904
5625
  }
4905
5626
  }
4906
5627
 
4907
- function watchWebsocket(baseUrl, topicId, taskId) {
5628
+ function watchWebsocket(baseUrl, topicId, taskId, options = {}) {
5629
+ const emit = options.stderr ? console.error : console.log;
4908
5630
  const client = new Client({
4909
5631
  brokerURL: webSocketUrl(baseUrl),
4910
5632
  reconnectDelay: 3000,
@@ -4923,7 +5645,7 @@ function watchWebsocket(baseUrl, topicId, taskId) {
4923
5645
  return;
4924
5646
  }
4925
5647
  if (payload.tests || payload.name || payload.total != null) {
4926
- console.log('[ws][TestSuite]', JSON.stringify(payload));
5648
+ emit('[ws][TestSuite]', JSON.stringify(payload));
4927
5649
  }
4928
5650
  }
4929
5651
  } catch (err) {
@@ -4954,6 +5676,7 @@ async function testCommand(flags) {
4954
5676
  : DEFAULT_DATA_MODE;
4955
5677
  const dataModeSource = dataModeFlagSource(flags);
4956
5678
  const testRef = flags.test || flags['test-id'] || flags.name;
5679
+ const jsonOutput = flagEnabled(flags.json);
4957
5680
 
4958
5681
  if (!testRef) {
4959
5682
  throw new Error('Missing --test <id-or-name>');
@@ -4988,12 +5711,14 @@ async function testCommand(flags) {
4988
5711
  // from the account's subscription edge, which is what production does.
4989
5712
  const variantBranch = flags['variant-branch'];
4990
5713
 
4991
- printProdDataBanner({
4992
- dataMode,
4993
- accountId: asAccountId || accountId,
4994
- baseUrl,
4995
- operation: 'test run ' + String(testRef),
4996
- mutating: true
5714
+ withStdoutRoutedToStderr(jsonOutput, () => {
5715
+ printProdDataBanner({
5716
+ dataMode,
5717
+ accountId: asAccountId || accountId,
5718
+ baseUrl,
5719
+ operation: 'test run ' + String(testRef),
5720
+ mutating: true
5721
+ });
4997
5722
  });
4998
5723
 
4999
5724
  const start = await loggedPost(api, cwd, '/cli/test', {
@@ -5014,20 +5739,22 @@ async function testCommand(flags) {
5014
5739
  throw new Error('Failed to start test run');
5015
5740
  }
5016
5741
 
5017
- printSessionResolutionWarning(sessionContext);
5018
- printResolvedBaseUrl(baseUrl);
5019
- printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
5020
- if (start.staging && Array.isArray(start.staging.accountLanes)) {
5021
- printAccountLanes({ accountLanes: start.staging.accountLanes, branchName, workspace });
5022
- }
5023
- console.log('Test run started:', start.taskId);
5742
+ withStdoutRoutedToStderr(jsonOutput, () => {
5743
+ printSessionResolutionWarning(sessionContext);
5744
+ printResolvedBaseUrl(baseUrl);
5745
+ printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
5746
+ if (start.staging && Array.isArray(start.staging.accountLanes)) {
5747
+ printAccountLanes({ accountLanes: start.staging.accountLanes, branchName, workspace });
5748
+ }
5749
+ console.log('Test run started:', start.taskId);
5750
+ });
5024
5751
  runtimeState.currentTestTaskId = start.taskId;
5025
5752
 
5026
5753
  let stopWs = null;
5027
5754
  if (flags.watch !== 'false') {
5028
5755
  const topic = session.websocketTopic || start.websocketTopic || (session.user && String(session.user.uuid || '').replace(/-/g, ''));
5029
5756
  if (topic) {
5030
- stopWs = watchWebsocket(baseUrl, topic, start.taskId);
5757
+ stopWs = watchWebsocket(baseUrl, topic, start.taskId, { stderr: jsonOutput });
5031
5758
  }
5032
5759
  }
5033
5760
 
@@ -5039,7 +5766,11 @@ async function testCommand(flags) {
5039
5766
  runtimeState.currentTestTaskId = null;
5040
5767
  }
5041
5768
 
5042
- console.log('Final status:', JSON.stringify(status, null, 2));
5769
+ if (jsonOutput) {
5770
+ console.log(JSON.stringify(status, null, 2));
5771
+ } else {
5772
+ console.log('Final status:', JSON.stringify(status, null, 2));
5773
+ }
5043
5774
 
5044
5775
  // A selector that matched no case is a mis-specified run, not a passing one. Say so in the terminal
5045
5776
  // and exit non-zero, or "0 passed, 0 failed" reads exactly like a suite where everything passed.
@@ -5085,7 +5816,7 @@ async function testCommand(flags) {
5085
5816
  limitations: unmatched.length ? ['One or more requested test case selectors matched no case.'] : [],
5086
5817
  rawRefs: { testStatusKey: start.taskId },
5087
5818
  status
5088
- });
5819
+ }, { quiet: jsonOutput });
5089
5820
  }
5090
5821
 
5091
5822
  async function tokenCommand(flags) {
@@ -5364,6 +6095,7 @@ async function toolCommand(flags) {
5364
6095
  const pollIntervalMs = parsePositiveInt(flags['poll-interval-ms'] || flags.pollIntervalMs, 1000);
5365
6096
  const waitTimeoutMs = flags['wait-timeout-ms'] || flags.waitTimeoutMs;
5366
6097
  const api = buildAxios(baseUrl, session.token, timeoutMs);
6098
+ const jsonOutput = flagEnabled(flags.json);
5367
6099
 
5368
6100
  if (statusMode) {
5369
6101
  const requestedCallId = String(flags['call-id'] || flags.callId || '');
@@ -5388,18 +6120,12 @@ async function toolCommand(flags) {
5388
6120
  throw new Error(data.message || 'Tool status lookup failed');
5389
6121
  }
5390
6122
 
5391
- printSessionResolutionWarning(sessionContext);
5392
- printResolvedBaseUrl(baseUrl);
5393
- console.log('Tool call status:', data.status);
5394
- console.log('Call ID:', requestedCallId);
5395
- console.log('Data mode:', data.dataMode || dataMode);
5396
- if (data.threadGroupingId) console.log('Thread grouping ID:', data.threadGroupingId);
5397
- console.log('Session log:', sessionJsonlFile(cwd));
5398
- console.log('Tool response file:', statusResponse.responseFile);
6123
+ data.responseFile = statusResponse.responseFile;
6124
+ data.sessionLog = sessionJsonlFile(cwd);
5399
6125
  // A completed run can still carry a tool-level refusal — see toolResultFailed.
5400
6126
  const polledFailed = data.toolSuccess === false || toolResultFailed(data.result);
5401
6127
  if (polledFailed) {
5402
- console.log('Tool error:', data.toolMessage || toolResultMessage(data.result));
6128
+ data.toolMessage = data.toolMessage || toolResultMessage(data.result);
5403
6129
  }
5404
6130
  if (data.status === 'failed' || polledFailed) process.exitCode = 1;
5405
6131
  await appendVerificationPacket(api, cwd, session, accountId, flags, {
@@ -5412,7 +6138,22 @@ async function toolCommand(flags) {
5412
6138
  rawRefs: { toolResponsePath: statusResponse.responseFile, callId: requestedCallId },
5413
6139
  tool: { callId: requestedCallId, status: data.status, name: stored && stored.name },
5414
6140
  result: data
5415
- });
6141
+ }, { quiet: jsonOutput });
6142
+ if (jsonOutput) {
6143
+ console.log(JSON.stringify(data, null, 2));
6144
+ return;
6145
+ }
6146
+ printSessionResolutionWarning(sessionContext);
6147
+ printResolvedBaseUrl(baseUrl);
6148
+ console.log('Tool call status:', data.status);
6149
+ console.log('Call ID:', requestedCallId);
6150
+ console.log('Data mode:', data.dataMode || dataMode);
6151
+ if (data.threadGroupingId) console.log('Thread grouping ID:', data.threadGroupingId);
6152
+ console.log('Session log:', data.sessionLog);
6153
+ console.log('Tool response file:', data.responseFile);
6154
+ if (polledFailed) {
6155
+ console.log('Tool error:', data.toolMessage);
6156
+ }
5416
6157
  return;
5417
6158
  }
5418
6159
 
@@ -5422,7 +6163,8 @@ async function toolCommand(flags) {
5422
6163
  baseUrl,
5423
6164
  operation: toolOperationLabel(toolName, input),
5424
6165
  mutating: isMutatingToolCall(toolName, input),
5425
- dryRun: looksLikeDryRun(input)
6166
+ dryRun: looksLikeDryRun(input),
6167
+ stderr: jsonOutput
5426
6168
  });
5427
6169
 
5428
6170
  const response = await loggedPost(api, cwd, '/cli/tool', {
@@ -5455,24 +6197,29 @@ async function toolCommand(flags) {
5455
6197
  // result so this still works against an older platform build.
5456
6198
  const toolFailed = data.toolSuccess === false || toolResultFailed(data.result);
5457
6199
  const toolFailureMessage = data.toolMessage || toolResultMessage(data.result);
6200
+ data.responseFile = response.responseFile;
6201
+ data.sessionLog = sessionJsonlFile(cwd);
6202
+ if (toolFailureMessage && !data.toolMessage) data.toolMessage = toolFailureMessage;
5458
6203
 
5459
- printSessionResolutionWarning(sessionContext);
5460
- printResolvedBaseUrl(baseUrl);
5461
- console.log(asyncMode ? 'Tool call started.'
5462
- : (toolFailed ? 'Tool call FAILED — the tool ran and returned an error.' : 'Tool call succeeded.'));
5463
- if (toolFailed && toolFailureMessage) console.log('Tool error:', toolFailureMessage);
5464
- console.log('Call ID:', callId);
5465
- console.log('Data mode:', data.dataMode || dataMode);
5466
- if (data.variantBranch || variantBranch) console.log('Variant branch:', data.variantBranch || variantBranch);
5467
- if (data.status) console.log('Status:', data.status);
5468
- if (data.threadGroupingId) console.log('Thread grouping ID:', data.threadGroupingId);
5469
- // Make the resolved component version explicit so callers never assume staged vs DB.
5470
- if (data.componentSource) {
5471
- const sig = data.componentSignature ? ` (${data.componentSignature})` : '';
5472
- console.log('Component:', `${data.componentSource}${sig}`);
6204
+ if (!jsonOutput) {
6205
+ printSessionResolutionWarning(sessionContext);
6206
+ printResolvedBaseUrl(baseUrl);
6207
+ console.log(asyncMode ? 'Tool call started.'
6208
+ : (toolFailed ? 'Tool call FAILED — the tool ran and returned an error.' : 'Tool call succeeded.'));
6209
+ if (toolFailed && toolFailureMessage) console.log('Tool error:', toolFailureMessage);
6210
+ console.log('Call ID:', callId);
6211
+ console.log('Data mode:', data.dataMode || dataMode);
6212
+ if (data.variantBranch || variantBranch) console.log('Variant branch:', data.variantBranch || variantBranch);
6213
+ if (data.status) console.log('Status:', data.status);
6214
+ if (data.threadGroupingId) console.log('Thread grouping ID:', data.threadGroupingId);
6215
+ // Make the resolved component version explicit so callers never assume staged vs DB.
6216
+ if (data.componentSource) {
6217
+ const sig = data.componentSignature ? ` (${data.componentSignature})` : '';
6218
+ console.log('Component:', `${data.componentSource}${sig}`);
6219
+ }
6220
+ console.log('Session log:', data.sessionLog);
6221
+ console.log('Tool response file:', data.responseFile);
5473
6222
  }
5474
- console.log('Session log:', sessionJsonlFile(cwd));
5475
- console.log('Tool response file:', response.responseFile);
5476
6223
 
5477
6224
  // Non-zero exit so scripted/agent callers that check status notice the refusal too.
5478
6225
  if (toolFailed) process.exitCode = 1;
@@ -5489,11 +6236,16 @@ async function toolCommand(flags) {
5489
6236
  waitTimeoutMs
5490
6237
  });
5491
6238
 
5492
- console.log('Final status:', finalStatus.data.status);
5493
- console.log('Tool response file:', finalStatus.responseFile);
6239
+ finalStatus.data.responseFile = finalStatus.responseFile;
6240
+ finalStatus.data.sessionLog = sessionJsonlFile(cwd);
6241
+ if (!jsonOutput) {
6242
+ console.log('Final status:', finalStatus.data.status);
6243
+ console.log('Tool response file:', finalStatus.responseFile);
6244
+ }
5494
6245
  if (finalStatus.data.status !== 'completed') {
5495
6246
  process.exitCode = 1;
5496
6247
  }
6248
+ if (jsonOutput) Object.assign(data, { finalStatus: finalStatus.data });
5497
6249
  }
5498
6250
 
5499
6251
  await appendVerificationPacket(api, cwd, session, accountId, flags, {
@@ -5512,7 +6264,10 @@ async function toolCommand(flags) {
5512
6264
  rawRefs: { toolResponsePath: response.responseFile, callId },
5513
6265
  tool: { callId, name: String(toolName), async: asyncMode, status: data.status, toolSuccess: data.toolSuccess },
5514
6266
  result: data
5515
- });
6267
+ }, { quiet: jsonOutput });
6268
+ if (jsonOutput) {
6269
+ console.log(JSON.stringify(data, null, 2));
6270
+ }
5516
6271
  }
5517
6272
 
5518
6273
  async function verifyCommand(flags, subcommand) {
@@ -11355,7 +12110,7 @@ function printComponentsHelp(subcommand) {
11355
12110
  return;
11356
12111
  }
11357
12112
  if (subcommand === 'commit') {
11358
- 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]');
12113
+ console.log('Usage: remits-cli components commit [--yes] [--safe] [--message|-m "msg"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--json [--summary]]');
11359
12114
  console.log('');
11360
12115
  console.log('Runs staged compile validation, local git add/commit/push, server sync, then fast-forward pull.');
11361
12116
  console.log('Prefer explicit stage + git + components sync when you need inspectable phases.');
@@ -11376,7 +12131,7 @@ function printComponentsHelp(subcommand) {
11376
12131
  console.log(' remits-cli components entries --lane-id ID [--base-url URL] [--account-id ID] [--json|--verbose]');
11377
12132
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
11378
12133
  console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
11379
- 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]');
12134
+ console.log(' remits-cli components commit [--yes] [--safe] [--message|-m "msg"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--json [--summary]]');
11380
12135
  console.log('');
11381
12136
  console.log(' --force-tombstones applies only on non-trunk variant syncs, when a missing trunk');
11382
12137
  console.log(' component file is intentionally being recorded as a tombstone override.');
@@ -11396,7 +12151,7 @@ function printComponentsHelp(subcommand) {
11396
12151
  }
11397
12152
 
11398
12153
  function printTestHelp() {
11399
- 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]');
12154
+ 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]');
11400
12155
  console.log('');
11401
12156
  console.log('Runs a Test component against the staged/variant world for this checkout.');
11402
12157
  console.log('Examples:');
@@ -11411,6 +12166,7 @@ function printTestHelp() {
11411
12166
  console.log(' to force production/subscription semantics from a variant checkout.');
11412
12167
  console.log(' --branch changes only the CLI staging namespace for test execution. Pair an unused');
11413
12168
  console.log(' value with --variant-branch none when existing staged entries would shadow DB rows.');
12169
+ console.log(' --json prints only the final status JSON to stdout; banners and progress go to stderr.');
11414
12170
  console.log(' --data-mode prod intentionally targets live production data.');
11415
12171
  }
11416
12172
 
@@ -11567,14 +12323,14 @@ async function main() {
11567
12323
  console.log(' remits-cli components entries --lane-id ID [--base-url URL] [--account-id ID] [--json|--verbose]');
11568
12324
  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]');
11569
12325
  console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
11570
- console.log(' remits-cli components commit [--message \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
12326
+ console.log(' remits-cli components commit [--yes] [--safe] [--message|-m \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--json [--summary]]');
11571
12327
  console.log(' remits-cli components promotion [<branch>] [--json] [--no-fail] # promotion readiness + ordered next steps');
11572
12328
  console.log(' remits-cli components branches [--json] # committed branch variants for this account');
11573
12329
  console.log(' remits-cli components branch <name> [--diff <componentId> --component-type <kind>] [--subscribers] [--json]');
11574
12330
  console.log(' remits-cli components branch <name> --subscribe <accountId> [--dry-run] [--confirm-primary-edge]');
11575
12331
  console.log(' remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk');
11576
12332
  console.log(' remits-cli components branch <name> --retire [--force] # delete the branch\'s overlays');
11577
- 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]');
12333
+ 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]');
11578
12334
  console.log(' remits-cli token [--base-url URL] [--branch BRANCH] [--path embeddable/path] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none]');
11579
12335
  console.log(' remits-cli token inspect --token <token|tokenKey|URL> [--base-url URL] [--account-id ID]');
11580
12336
  console.log('');