@remits/remits-cli 0.1.104 → 0.1.106

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
@@ -74,7 +74,7 @@ const ACCOUNT_SCAN_EXCLUDE_DIRS = new Set([
74
74
 
75
75
  // Flags that are legitimately repeatable accumulate into an array instead of last-wins. Every other
76
76
  // flag keeps last-wins so existing callers are unaffected.
77
- const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved']);
77
+ const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved', 'names']);
78
78
 
79
79
  function parseArgs(argv) {
80
80
  const out = { _: [] };
@@ -1572,6 +1572,42 @@ function changedComponentsFromWorkingTree(cwd) {
1572
1572
  });
1573
1573
  }
1574
1574
 
1575
+ /**
1576
+ * Components touched by commits between `ref` and HEAD.
1577
+ *
1578
+ * `changedComponentsFromWorkingTree` reads `git status`, i.e. UNCOMMITTED edits only — which is empty
1579
+ * at exactly the moment `--changed-only` is meant to run, because the documented flow is
1580
+ * commit -> push -> sync (sync reads the pushed remote, so it cannot see uncommitted work at all).
1581
+ * The gate was therefore unusable in the flow it exists to guard: it refused every plan with
1582
+ * "plan touches N component(s) this working tree did not edit". `--changed-since <ref>` supplies the
1583
+ * base so the same guard works after the commit.
1584
+ */
1585
+ function changedComponentsSinceRef(cwd, ref) {
1586
+ let output = '';
1587
+ try {
1588
+ output = execSync('git diff --name-only ' + JSON.stringify(ref) + '...HEAD -- components', {
1589
+ cwd,
1590
+ stdio: ['ignore', 'pipe', 'pipe']
1591
+ }).toString();
1592
+ } catch (_) {
1593
+ return null;
1594
+ }
1595
+ const byKey = new Map();
1596
+ for (const rawLine of output.split(/\r?\n/)) {
1597
+ const filePath = rawLine.trim();
1598
+ if (!filePath) continue;
1599
+ const info = componentPathInfo(cwd, filePath);
1600
+ if (!info) continue;
1601
+ if (!byKey.has(info.key)) {
1602
+ byKey.set(info.key, { type: info.type, id: info.id, name: info.name, fields: [], paths: [] });
1603
+ }
1604
+ const entry = byKey.get(info.key);
1605
+ if (!entry.fields.includes(info.field)) entry.fields.push(info.field);
1606
+ if (!entry.paths.includes(info.path)) entry.paths.push(info.path);
1607
+ }
1608
+ return Array.from(byKey.values());
1609
+ }
1610
+
1575
1611
  function parsePositiveInt(value, fallback) {
1576
1612
  const parsed = Number(value);
1577
1613
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
@@ -2309,7 +2345,7 @@ async function syncComponentsCommand(flags) {
2309
2345
  throw new Error(previewResponse.message || 'Server sync dry-run failed');
2310
2346
  }
2311
2347
 
2312
- preflightGate = evaluateSyncGates(previewResponse, flags, changedFromWorkingTree);
2348
+ preflightGate = evaluateSyncGates(previewResponse, flags, changedFromWorkingTree, cwd);
2313
2349
 
2314
2350
  if (namesOnly) {
2315
2351
  printSessionResolutionWarning(sessionContext);
@@ -2332,7 +2368,7 @@ async function syncComponentsCommand(flags) {
2332
2368
  }
2333
2369
 
2334
2370
  const summary = buildSyncSummary(response);
2335
- const gate = preflightGate || evaluateSyncGates(response, flags, changedFromWorkingTree);
2371
+ const gate = preflightGate || evaluateSyncGates(response, flags, changedFromWorkingTree, cwd);
2336
2372
  summary.gates = gate.checks;
2337
2373
 
2338
2374
  if (flagEnabled(flags.json)) {
@@ -2505,7 +2541,7 @@ function parseExpectedRemoved(flags) {
2505
2541
  }
2506
2542
 
2507
2543
  // Fail-closed gates. Each returns a violation string or null; the command exits non-zero if any fire.
2508
- function evaluateSyncGates(response, flags, changedFromWorkingTree) {
2544
+ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
2509
2545
  const entries = syncPlanEntries(response);
2510
2546
  const results = ((response && response.sync) || {}).syncResults || {};
2511
2547
  const removed = entries.filter((e) => e.bucket === 'removed');
@@ -2536,13 +2572,19 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree) {
2536
2572
  }
2537
2573
 
2538
2574
  if (flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly)) {
2539
- if (changedFromWorkingTree === null) {
2575
+ const since = flags['changed-since'] || flags.changedSince;
2576
+ const fromRef = since ? changedComponentsSinceRef(cwd, String(since)) : null;
2577
+ if (since && fromRef === null) {
2578
+ violations.push('--changed-since: could not diff against "' + since + '". Check the ref exists (git fetch first for a remote ref).');
2579
+ checks.changedOnly = false;
2580
+ } else if (changedFromWorkingTree === null && fromRef === null) {
2540
2581
  violations.push('--changed-only: this checkout is not a git working tree, so the changed set cannot be established.');
2541
2582
  checks.changedOnly = false;
2542
2583
  } else {
2584
+ const changedSet = (changedFromWorkingTree || []).concat(fromRef || []);
2543
2585
  // A component the checkout edited is identified by type + id, or type + name for `new_` files.
2544
- const allowedIds = new Set(changedFromWorkingTree.filter((c) => c.id != null).map((c) => c.type + ':' + c.id));
2545
- const allowedNames = new Set(changedFromWorkingTree.filter((c) => c.name).map((c) => c.type + ':' + String(c.name).toLowerCase()));
2586
+ const allowedIds = new Set(changedSet.filter((c) => c.id != null).map((c) => c.type + ':' + c.id));
2587
+ const allowedNames = new Set(changedSet.filter((c) => c.name).map((c) => c.type + ':' + String(c.name).toLowerCase()));
2546
2588
  const unexpected = entries.filter((entry) => {
2547
2589
  if (entry.id != null && allowedIds.has(entry.type + ':' + entry.id)) return false;
2548
2590
  if (entry.name && allowedNames.has(entry.type + ':' + String(entry.name).toLowerCase())) return false;
@@ -2550,8 +2592,15 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree) {
2550
2592
  });
2551
2593
  checks.changedOnly = unexpected.length === 0;
2552
2594
  if (unexpected.length) {
2553
- violations.push('--changed-only: plan touches ' + unexpected.length + ' component(s) this working tree did not edit: ' +
2554
- unexpected.slice(0, 20).map(syncEntryToken).join(', ') + (unexpected.length > 20 ? ', ...' : ''));
2595
+ // Name the most likely cause instead of only the symptom. An empty changed set with a non-empty
2596
+ // plan is the ordinary post-commit state, not evidence that the plan is dangerous.
2597
+ const hint = (!changedSet.length && !since)
2598
+ ? '\n The changed set is EMPTY: --changed-only reads UNCOMMITTED edits, and the documented flow ' +
2599
+ 'commits and pushes before syncing. Pass --changed-since <ref> (e.g. the commit you branched ' +
2600
+ 'from, or origin/main) so the gate can see committed work.'
2601
+ : '';
2602
+ violations.push('--changed-only: plan touches ' + unexpected.length + ' component(s) this checkout did not change: ' +
2603
+ unexpected.slice(0, 20).map(syncEntryToken).join(', ') + (unexpected.length > 20 ? ', ...' : '') + hint);
2555
2604
  }
2556
2605
  }
2557
2606
  }
@@ -2571,6 +2620,36 @@ function buildSyncSummary(response) {
2571
2620
  const errors = Array.isArray(results.errors) ? results.errors : [];
2572
2621
  const skipped = Array.isArray(results.skipped) ? results.skipped : [];
2573
2622
 
2623
+ // A sync that SHORT-CIRCUITED on the cached branch SHA did no work at all, and the server says so
2624
+ // (`skipped: true` plus a message). Rendering it through the normal shape below printed
2625
+ // `overridden: 0, added: 0, removed: []` — which is what a sync that ran and found nothing to do
2626
+ // prints, so the two were indistinguishable. That matters because the cache can hold a SHA whose
2627
+ // overlays were since removed by another path: an ordinary re-sync then reports zeros forever and
2628
+ // the operator has no reason to suspect the branch was never re-read. Report the skip as itself.
2629
+ // `syncResults` absent is the fallback signal, so this still reads correctly against a server that
2630
+ // predates the explicit `skipped` flag rather than silently falling back to the misleading zeros.
2631
+ const shortCircuited = sync.skipped === true
2632
+ || (sync.syncResults == null && !!sync.message && sync.dryRun !== true);
2633
+ if (shortCircuited) {
2634
+ return {
2635
+ success: response && response.success === true,
2636
+ accountId: response && response.accountId,
2637
+ branchName: sync.branchName || (response && response.branchName),
2638
+ mode: sync.mode || 'variant',
2639
+ skipped: true,
2640
+ reason: sync.message || 'branch head matches the cached sync SHA; the branch was not re-read',
2641
+ branchHeadSha: sync.branchHeadSha || sync.postSyncSha || null,
2642
+ warnings: [
2643
+ // Scoped to COMPONENTS deliberately: the short-circuit still refreshes the delivered guides and
2644
+ // account metadata, so "nothing happened" would be its own small untruth.
2645
+ 'NO COMPONENTS WERE SYNCED. This is the cached-SHA short-circuit, not an empty plan — the ' +
2646
+ 'branch was never re-read.',
2647
+ 'Use --dry-run to see the real plan (a dry run always re-reads the branch), or ' +
2648
+ '--force-tombstones to re-read and apply it.'
2649
+ ]
2650
+ };
2651
+ }
2652
+
2574
2653
  // A TRUNK sync and a VARIANT sync report in different vocabularies, and this summary only ever spoke
2575
2654
  // variant. So the single most consequential line of a promotion — "component X was CREATED on trunk as
2576
2655
  // id N" — printed as `added: 0`, and the runbook's definition of done ("components created, with their
@@ -3153,7 +3232,26 @@ async function testCommand(flags) {
3153
3232
  throw new Error('Missing --test <id-or-name>');
3154
3233
  }
3155
3234
 
3156
- const names = flags.names ? String(flags.names).split(',').map((s) => s.trim()).filter(Boolean) : [];
3235
+ // Test case names are English prose, so COMMAS occur in them naturally. A comma-delimited selector
3236
+ // therefore split a legitimate name in half, matched nothing, and (before the server learned to
3237
+ // report unmatched selectors) reported a clean run of zero cases. `|` is the delimiter now; the
3238
+ // comma still works when no `|` is present, so existing invocations keep behaving as before.
3239
+ // `--names` may also be repeated, which needs no delimiter at all.
3240
+ const rawNames = flags.names === undefined || flags.names === null
3241
+ ? []
3242
+ : (Array.isArray(flags.names) ? flags.names : [flags.names]);
3243
+ const repeated = rawNames.length > 1;
3244
+ const names = rawNames
3245
+ .flatMap((value) => {
3246
+ const text = String(value);
3247
+ if (text.includes('|')) return text.split('|');
3248
+ // Comma-splitting is the legacy behaviour, kept so existing invocations still work. It is applied
3249
+ // ONLY to a single --names value: repeating the flag is already unambiguous, so splitting there
3250
+ // would take a deliberate literal name back apart.
3251
+ return repeated ? [text] : text.split(',');
3252
+ })
3253
+ .map((s) => s.trim())
3254
+ .filter(Boolean);
3157
3255
 
3158
3256
  const api = buildAxios(baseUrl, session.token);
3159
3257
  // --as-account runs the Test AS a (descendant) subscriber account, so that account's relationship
@@ -3209,6 +3307,23 @@ async function testCommand(flags) {
3209
3307
  }
3210
3308
 
3211
3309
  console.log('Final status:', JSON.stringify(status, null, 2));
3310
+
3311
+ // A selector that matched no case is a mis-specified run, not a passing one. Say so in the terminal
3312
+ // and exit non-zero, or "0 passed, 0 failed" reads exactly like a suite where everything passed.
3313
+ const unmatched = (status.result && status.result.unmatchedTestNames) || [];
3314
+ if (unmatched.length) {
3315
+ console.error('');
3316
+ console.error('No test case matched: ' + unmatched.map((n) => '"' + n + '"').join(', '));
3317
+ const declared = (status.result && status.result.declaredTestNames) || [];
3318
+ if (declared.length) {
3319
+ console.error('Cases declared by this suite:');
3320
+ declared.forEach((n) => console.error(' - ' + n));
3321
+ }
3322
+ console.error('--names is delimited by "|" (a comma still works when no "|" is present), so a case');
3323
+ console.error('name containing a comma must be passed with "|" or by repeating --names.');
3324
+ process.exitCode = 1;
3325
+ }
3326
+
3212
3327
  if (status.status !== 'completed') {
3213
3328
  process.exitCode = 1;
3214
3329
  } else if (status.result && status.result.failed > 0) {
@@ -5977,6 +6092,8 @@ function printComponentsHelp(subcommand) {
5977
6092
  console.log('');
5978
6093
  console.log('Agent safety gates (each exits non-zero instead of printing a wall of JSON):');
5979
6094
  console.log(' --changed-only dry-run first; fail unless every planned write is a component this checkout changed');
6095
+ console.log(' --changed-since <ref> pair with --changed-only after committing: the changed set becomes');
6096
+ console.log(' the components touched between <ref> and HEAD, plus uncommitted edits');
5980
6097
  console.log(' --names-only dry-run first; print only "type id name" lines and write nothing');
5981
6098
  console.log(' --fail-on-removed dry-run first; fail if the plan removes/tombstones anything');
5982
6099
  console.log(' --fail-on-errors dry-run first; fail if the server reported any per-component sync error');
@@ -6037,7 +6154,7 @@ function printComponentsHelp(subcommand) {
6037
6154
  }
6038
6155
 
6039
6156
  function printTestHelp() {
6040
- console.log('Usage: remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names name1,name2] [--watch true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none]');
6157
+ 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]');
6041
6158
  console.log('');
6042
6159
  console.log('Runs a Test component against the staged/variant world for this checkout.');
6043
6160
  console.log('Examples:');
@@ -6045,7 +6162,8 @@ function printTestHelp() {
6045
6162
  console.log(' remits-cli test run --test "Merchant Statements" --names "managed account case"');
6046
6163
  console.log('');
6047
6164
  console.log('Notes:');
6048
- console.log(' --names is comma-delimited, so avoid commas in individual test case names.');
6165
+ console.log(' --names is delimited by "|" (comma also works when no "|" is present), and may be repeated.');
6166
+ console.log(' A selector that matches no case fails the run rather than reporting 0 passed / 0 failed.');
6049
6167
  console.log(' --as-account changes the execution account so subscriber branch edges apply.');
6050
6168
  console.log(' --variant-branch <name> probes a committed variant branch; use "none" or "trunk"');
6051
6169
  console.log(' to force production/subscription semantics from a variant checkout.');
@@ -6146,7 +6264,7 @@ async function main() {
6146
6264
  console.log(' remits-cli components branch <name> --subscribe <accountId> [--dry-run] [--confirm-primary-edge]');
6147
6265
  console.log(' remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk');
6148
6266
  console.log(' remits-cli components branch <name> --retire [--force] # delete the branch\'s overlays');
6149
- console.log(' remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names name1,name2] [--watch true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME]');
6267
+ 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]');
6150
6268
  console.log(' remits-cli token [--base-url URL] [--branch BRANCH] [--path embeddable/path] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none]');
6151
6269
  console.log(' remits-cli token inspect --token <token|tokenKey|URL> [--base-url URL] [--account-id ID]');
6152
6270
  console.log('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remits/remits-cli",
3
- "version": "0.1.104",
3
+ "version": "0.1.106",
4
4
  "description": "Local CLI for auth, component sync, and live test execution against Remits",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -339,6 +339,14 @@ file is catastrophic.
339
339
  - Local branch is committed and pushed; sync will read the intended remote commit.
340
340
  - Local filenames and live inventory (`mcp_account_view`) **agree on id and name for every component**: no live
341
341
  component appears locally under a different id, and no expected component is missing a repo file.
342
+ > **Do not run this comparison against `account-info.json` alone — it will report false orphans.** That
343
+ > file omits `auxiliary: true` components by design, so every auxiliary component looks like a repo file
344
+ > with no DB row, i.e. exactly the "a trunk sync will CREATE a duplicate" signal this check exists to
345
+ > catch. It also omits README- and AGENT-purpose Prompts, which live at the repo root and as `.md`
346
+ > sidecars in `components/agents/` rather than in `components/prompts/`, so they look like DB rows with
347
+ > no repo file — the "will be DELETED" signal. Both are benign. Before treating a flagged component as
348
+ > drift, confirm against the live row: `mcp_component_view`, or
349
+ > `mcp_run_action controlAction:"describe"` for an Action. A component that answers is not an orphan.
342
350
  - You can state the expected create/update/delete set. **If any delete or renumber is unexpected, stop.**
343
351
 
344
352
  ### If something looks wrong — stop, don't paper over
@@ -895,7 +903,16 @@ Tests run on the platform against your staged snapshot. They stream results in r
895
903
 
896
904
  Important test-runner constraints:
897
905
  - `remits-cli test run` now defaults to `test` dataMode unless you explicitly pass `--data-mode prod`.
898
- - Do not put commas in individual `test("...")` names. The CLI `--names` filter is comma-delimited, so comma-bearing test names cannot be targeted cleanly as a single selected case.
906
+ - **`--names` is delimited by `|`, and may be repeated.** A comma still splits a single `--names` value
907
+ (legacy behaviour), which is why a case name containing a comma used to be cut in half and match
908
+ nothing. Prefer `|` or repetition whenever a name might contain punctuation:
909
+ ```bash
910
+ remits-cli test run --test 13 --names "a case, with a comma|another case"
911
+ remits-cli test run --test 13 --names "a case, with a comma" --names "another case"
912
+ ```
913
+ - **A selector that matches no case FAILS the run.** It used to report `0 passed, 0 failed`,
914
+ `completed`, and exit 0 — indistinguishable from a suite where everything passed. The run now names
915
+ the unmatched selectors and lists the cases the suite actually declared, and exits non-zero.
899
916
 
900
917
  If no relevant Test component exists yet, consider creating one. Test components live in `components/tests/` and follow the same component structure. They provide permanent regression protection — every test you write today saves debugging time tomorrow.
901
918
 
@@ -1617,6 +1634,15 @@ a confirm-gated override when the removal guard refuses. Preview there is the sa
1617
1634
  - A test/tool response reports `testComponentSource` as `staged` | `variant` | `db`, so you can see which
1618
1635
  layer the run resolved without reading logs.
1619
1636
 
1637
+ > **A populated staging cache makes a variant look broken.** Anything carrying a CLI `TestMode` — a
1638
+ > `remits-cli token` URL, `/s/<tokenKey>/...`, an `X-Auth-Token` request, a script-loader embed — resolves
1639
+ > the STAGED layer, which outranks the variant. So with components staged under the same branch/user, a
1640
+ > tokenized page reports `Component Branch: <branch>` but `Variant Applied: none` and renders trunk, while
1641
+ > an anonymous `?account_id=` request to the same page reports the variant. That is the documented
1642
+ > precedence (staged -> variant -> trunk) working correctly, and it reads exactly like "tokens break
1643
+ > variant resolution". **Run `remits-cli components clear --all` before verifying variant resolution
1644
+ > through any tokenized entry point**, or check `remits-cli components status` first.
1645
+
1620
1646
  ## Production Support Workflow
1621
1647
 
1622
1648
  Switch to prod mode for investigations:
@@ -2804,7 +2830,7 @@ remits-cli data-mode [set test|prod]
2804
2830
  remits-cli components stage [--branch <name>] [--data-mode test|prod] [--json|--verbose]
2805
2831
  remits-cli components status [--branch <name>] [--component-type <type>] [--component-id <id>] [--json|--verbose]
2806
2832
  remits-cli components clear [--branch <name>] [--component-type <type>] [--component-id <id>] [--all] [--json|--verbose] # id alone scopes to one component when unambiguous (ids are type-local; add --component-type if the same id is staged in multiple families); no filter clears the whole branch scope; --all forces the full wipe
2807
- remits-cli components sync [--branch <name>] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] # gated; on trunk = full repo->DB reconcile, on a variant branch = ComponentVariant overlays only
2833
+ remits-cli components sync [--branch <name>] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] [--changed-only [--changed-since <ref>]] # gated; on trunk = full repo->DB reconcile, on a variant branch = ComponentVariant overlays only
2808
2834
  remits-cli components commit [--message "msg"] [--data-mode test|prod] [--force-tombstones]
2809
2835
  remits-cli components branches [--json] # branches carrying committed variants, with counts + drift
2810
2836
  remits-cli components branch <name> [--json] # one branch: overridden / added / removed, drift flags, subscribers
@@ -2813,7 +2839,7 @@ remits-cli components branch <name> --subscribers [--json]
2813
2839
  remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>] [--dry-run] [--confirm-primary-edge] # make an account resolve this branch
2814
2840
  remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk
2815
2841
  remits-cli components branch <name> --retire [--force] # delete the branch's overlays
2816
- remits-cli test run --test <id|name> [--branch <stagingScope>] [--names "a,b"] [--watch true|false] [--data-mode test|prod] [--as-account <ID>] [--variant-branch <name|none>]
2842
+ remits-cli test run --test <id|name> [--branch <stagingScope>] [--names "a|b"] [--watch true|false] [--data-mode test|prod] [--as-account <ID>] [--variant-branch <name|none>]
2817
2843
  remits-cli token [--path <embeddablePathOrId>] [--data-mode test|prod] [--as-account <ID>] [--variant-branch <name|none>]
2818
2844
  remits-cli token inspect --token <token|tokenKey|URL> # inspect token metadata, safety/dataMode evidence, and full context
2819
2845
  remits-cli tools [--branch <name>] [--data-mode test|prod] [--variant-branch <name|none>]
@@ -2823,7 +2849,8 @@ remits-cli tool status --call-id <callId> [--data-mode test|prod]
2823
2849
 
2824
2850
  For tests specifically:
2825
2851
  - If `--data-mode` is omitted, `remits-cli test run` uses `test`.
2826
- - `--names` is comma-delimited, so keep individual test names comma-free.
2852
+ - `--names` is `|`-delimited (a comma still splits a single value) and may be repeated; an unmatched
2853
+ selector fails the run instead of reporting zero cases as success.
2827
2854
  - `--as-account <ID>` runs AS a descendant subscriber so its edge selects the component branch
2828
2855
  (*"what does customer X get?"*); `--variant-branch <name>` probes a branch from any checkout
2829
2856
  (*"what does branch Y look like?"*), and `--variant-branch none` forces production/subscription semantics.
@@ -2841,10 +2868,16 @@ For tests specifically:
2841
2868
  - **Fail-closed sync gates.** On non-trunk variant branches these flags now force a server dry-run first,
2842
2869
  evaluate that plan before any overlay row is written, and only then run the mutating sync when the plan
2843
2870
  passes. On trunk, there is no safe dry-run plan, so do not treat these as scoped commit controls:
2844
- - `--changed-only` — fail unless every planned write is a component **this checkout actually edited**.
2871
+ - `--changed-only` — fail unless every planned write is a component **this checkout actually changed**.
2845
2872
  This is the strongest guard against a sync that quietly rewrites components you never touched. It
2846
2873
  also fails when the checkout is not a git working tree, because "git could not answer" must never
2847
2874
  be read as "nothing changed".
2875
+ > **Pair it with `--changed-since <ref>` after you have committed.** On its own `--changed-only`
2876
+ > reads UNCOMMITTED edits, and the documented flow commits and pushes *before* syncing (sync reads
2877
+ > the pushed remote, so it cannot see uncommitted work at all) — so the changed set is empty at
2878
+ > exactly the moment the gate runs, and it refuses the whole plan. `--changed-since origin/main`
2879
+ > (or the commit you branched from) makes the changed set the components your commits touched.
2880
+ > The refusal message says this when it detects the empty-set case.
2848
2881
  - `--fail-on-removed` — fail if the plan removes or tombstones anything.
2849
2882
  - `--expected-removed <type:id>` — whitelist the removals you intend (repeatable, or comma-delimited,
2850
2883
  e.g. `--expected-removed action:5,reader:9`). It **implies** `--fail-on-removed`, so any removal you
@@ -2902,6 +2935,8 @@ For tests specifically:
2902
2935
  | A component vanished for one account after a variant-branch sync | Its file is missing from that branch, so the sync created a **tombstone** that hides it from subscribers. Restore the file on the branch and re-sync. Trunk is unaffected. |
2903
2936
  | A variant Test suite fails wholesale, asserting trunk where you expect a variant | You are almost certainly running it from a **variant checkout**: `variantBranch` outranks every subscription, so the suite's own fixture accounts resolve YOUR branch. Re-run from trunk or with `--variant-branch none` before treating it as a regression. |
2904
2937
  | `components sync` on a branch says "No changes detected" but trunk has moved | Re-run it; a trunk sync now invalidates the branch's cached verdict. If it still skips, the branch genuinely matches trunk - check `components branch <name>` for what is actually stored. |
2938
+ | A sync summary reports `skipped: true` with `NO COMPONENTS WERE SYNCED` | The branch head matches the cached sync SHA, so the branch was never re-read — this is NOT an empty plan. It matters when something else changed the overlays at that same SHA: an ordinary re-sync then answers "nothing to do" forever. `--dry-run` always re-reads the branch and shows the real plan; `--force-tombstones` re-reads and applies it. |
2939
+ | A branch sync stores overlays for components you never edited on the branch | **Trunk moved.** Sparseness compares the branch against CURRENT trunk, so editing a component on trunk without merging trunk into the branch turns it into a branch overlay on the next branch sync. The branch did not change. Merge trunk in, push, re-sync — the overlays prune. Check `components promotion` for the phase. |
2905
2940
  | After promoting a `new_*` component to trunk, the branch still shows it as `added` | You have not re-synced the branch since the trunk sync. Merge trunk into the branch and `components sync`: the file adopts the promoted id and, if unchanged, removes its own overlay. If the branch carries BOTH `new_Foo.*` and `<id>_Foo.*`, the id file wins and the `new_` one is reported `skipped: superseded` - delete it. |
2906
2941
  | After promoting a standalone Prompt, the branch still shows `OVERRIDDEN prompt:<id>` with only `purpose` changed | The trunk Prompt row does not match repo metadata. Ensure the Prompt sidecar has `description` and `purpose: CUSTOM`, run a platform build that imports Prompt `purpose`, re-sync trunk, merge back, and re-sync the branch. |
2907
2942
  | A branch preview reports a `removed` component nobody deleted | Check whether that component has a file on **trunk**. A DB row with no trunk file is missing from every branch, so it reads as a removal everywhere (and the trunk sync tries to hard-delete it each run). Repair trunk, not the branch. |