@remits/remits-cli 0.1.121 → 0.1.124

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
@@ -19,6 +19,9 @@ const openBrowser = (openModule && typeof openModule === 'function')
19
19
 
20
20
  //const DEFAULT_BASE_URL = process.env.REMITS_BASE_URL || 'http://localhost:8080';
21
21
  const DEFAULT_BASE_URL = process.env.REMITS_BASE_URL || 'https://remits-529558023549.us-east5.run.app';
22
+
23
+ // How many compile failures print their full message before the rest are listed by identifier only.
24
+ const COMPILE_FAILURES_SHOWN = 5;
22
25
  const SESSION_DIR = path.join(os.homedir(), '.remits-cli');
23
26
  const SESSIONS_FILE = path.join(SESSION_DIR, 'sessions.json');
24
27
  const CONFIG_FILE = path.join(SESSION_DIR, 'config.json');
@@ -1816,6 +1819,19 @@ function resolveSessionContext(cwd, flags) {
1816
1819
  };
1817
1820
  }
1818
1821
 
1822
+ /**
1823
+ * Record the FILE identity of a component (type + filename id, or type + filename stem) on an object without
1824
+ * sending it anywhere. Non-enumerable, so JSON payloads and the content hash never see it.
1825
+ *
1826
+ * It exists because a component is named twice: by its files, and by its `.meta.yml` `name:`. For a `new_`
1827
+ * file there is no id, so the two can disagree (`new_PDFStatement` vs `name: PDF Statement`), and the file
1828
+ * identity is the only thing both the payload and the git changed set agree on.
1829
+ */
1830
+ function withFileKey(target, key) {
1831
+ Object.defineProperty(target, 'fileKey', { value: key, enumerable: false, configurable: true });
1832
+ return target;
1833
+ }
1834
+
1819
1835
  function collectComponents(cwd) {
1820
1836
  const mapping = {
1821
1837
  schemas: 'schema',
@@ -1863,7 +1879,7 @@ function collectComponents(cwd) {
1863
1879
  //
1864
1880
  // A partial stage (mcp_component_edit writing one field) deliberately does NOT set this and
1865
1881
  // keeps the merge semantics it needs.
1866
- byKey.set(key, { type, id, name, metadataAuthoritative: true });
1882
+ byKey.set(key, withFileKey({ type, id, name, metadataAuthoritative: true }, key));
1867
1883
  }
1868
1884
  const component = byKey.get(key);
1869
1885
  const filePath = path.join(dir, fileName);
@@ -2030,11 +2046,11 @@ function changedComponentsFromWorkingTree(cwd) {
2030
2046
  if (!entry.statuses.includes(status)) entry.statuses.push(status);
2031
2047
  if (!entry.paths.includes(info.path)) entry.paths.push(info.path);
2032
2048
  }
2033
- return Array.from(byKey.values()).map((entry) => ({
2049
+ return Array.from(byKey.entries()).map(([key, entry]) => withFileKey({
2034
2050
  ...entry,
2035
2051
  fields: entry.fields.sort(),
2036
2052
  paths: entry.paths.sort()
2037
- })).sort((a, b) => {
2053
+ }, key)).sort((a, b) => {
2038
2054
  const typeCmp = String(a.type).localeCompare(String(b.type));
2039
2055
  if (typeCmp !== 0) return typeCmp;
2040
2056
  return String(a.id || a.name || '').localeCompare(String(b.id || b.name || ''));
@@ -2068,7 +2084,7 @@ function changedComponentsSinceRef(cwd, ref) {
2068
2084
  const info = componentPathInfo(cwd, filePath);
2069
2085
  if (!info) continue;
2070
2086
  if (!byKey.has(info.key)) {
2071
- byKey.set(info.key, { type: info.type, id: info.id, name: info.name, fields: [], paths: [] });
2087
+ byKey.set(info.key, withFileKey({ type: info.type, id: info.id, name: info.name, fields: [], paths: [] }, info.key));
2072
2088
  }
2073
2089
  const entry = byKey.get(info.key);
2074
2090
  if (!entry.fields.includes(info.field)) entry.fields.push(info.field);
@@ -2756,7 +2772,7 @@ async function pushComponentsCommand(flags) {
2756
2772
  const dataMode = resolveDataMode(flags, session);
2757
2773
  const requestedMode = String(flags.mode || 'stage').toLowerCase();
2758
2774
  const mode = requestedMode === 'push' ? 'stage' : requestedMode;
2759
- const changedFromWorkingTree = changedComponentsFromWorkingTree(cwd);
2775
+ const changedFromGit = changedComponentsFromWorkingTree(cwd);
2760
2776
 
2761
2777
  // WHAT this stage is. Three shapes, and the difference between them is the difference between a lane
2762
2778
  // that reads as "7 components in flight" and one that reads as "115 staged":
@@ -2776,6 +2792,8 @@ async function pushComponentsCommand(flags) {
2776
2792
  const emptyWorksetPolicy = normalizeEmptyWorksetPolicy(flags);
2777
2793
 
2778
2794
  let components = collectComponents(cwd);
2795
+ // Named the way the payload is named, BEFORE anything compares the two — see alignChangedSetWithComponents.
2796
+ const changedFromWorkingTree = alignChangedSetWithComponents(changedFromGit, components);
2779
2797
  // Changes git reported that a component payload cannot carry — a deleted component file has nothing to
2780
2798
  // stage, and Redis staging has no way to say "hide this during CLI-scoped runs". Removing its staged
2781
2799
  // entry falls back to the committed row, so the component still resolves. Reported out loud rather than
@@ -2785,10 +2803,8 @@ async function pushComponentsCommand(flags) {
2785
2803
  if (changedFromWorkingTree === null) {
2786
2804
  throw new Error('--changed-only needs a git working tree to establish the changed set, and this directory is not one.');
2787
2805
  }
2788
- const wanted = new Set(changedFromWorkingTree.map((entry) =>
2789
- entry.type + ':' + (entry.id ? 'id:' + entry.id : 'name:' + String(entry.name || '').toLowerCase())));
2790
- components = components.filter((component) => wanted.has(
2791
- component.type + ':' + (component.id ? 'id:' + component.id : 'name:' + String(component.name || '').toLowerCase())));
2806
+ const wanted = new Set(changedFromWorkingTree.map(stageIdentityKey));
2807
+ components = components.filter((component) => wanted.has(stageIdentityKey(component)));
2792
2808
  if (!components.length && !(worksetReplace && emptyWorksetPolicy === 'clear')) {
2793
2809
  // Deliberately NOT an error, and deliberately not a clear. An agent that has not edited anything yet
2794
2810
  // is in an ordinary state; failing its loop teaches it nothing, and reconciling the lane to an empty
@@ -2817,7 +2833,7 @@ async function pushComponentsCommand(flags) {
2817
2833
  // or, worse, conclude staging was broken and go on reading trunk. Scaled by payload size, floored at
2818
2834
  // the old default, and overridable.
2819
2835
  const api = buildAxios(baseUrl, session.token, stageTimeoutMs(components, flags));
2820
- const response = await stageOrRefuse(api, cwd, {
2836
+ const stagePayload = {
2821
2837
  token: session.token,
2822
2838
  accountId,
2823
2839
  branchName,
@@ -2846,11 +2862,50 @@ async function pushComponentsCommand(flags) {
2846
2862
  // Kept for a platform that predates stageMode. Same meaning it always had.
2847
2863
  replace: !changedOnly,
2848
2864
  components
2849
- });
2865
+ };
2866
+
2867
+ let response;
2868
+ try {
2869
+ response = await stageOrRefuse(api, cwd, stagePayload);
2870
+ } catch (err) {
2871
+ // A REFUSED stage is evidence too. Recording it only on the success path is how a lane ends up with
2872
+ // an envelope that says nothing happened between two packets, when in fact a stage was attempted and
2873
+ // the platform rejected it. The packet is best-effort: a failing stage must surface the STAGE error,
2874
+ // never an error from writing the record of it.
2875
+ try {
2876
+ await appendVerificationPacket(api, cwd, session, accountId, flags, {
2877
+ type: 'stage',
2878
+ success: false,
2879
+ claim: 'Components refused at staging',
2880
+ world: buildCommandWorld(err.responseBody || {}, { accountId, dataMode, branchName, workspace, host: normalizeBaseUrl(baseUrl), sourceLayer: 'staged' }),
2881
+ revision: collectVerificationSource(cwd, flags),
2882
+ assertions: [
2883
+ { id: 'stage', stageMode },
2884
+ { id: 'stage_refused', value: err.message }
2885
+ ],
2886
+ evidenceCategories: ['stage'],
2887
+ rawRefs: { command: 'components stage' },
2888
+ stage: (err.responseBody || {}).stage,
2889
+ compileValidation: (err.responseBody || {}).compileValidation,
2890
+ changedFromWorkingTree: changedFromWorkingTree || [],
2891
+ unrepresentableChanges: unstageable
2892
+ }, { quiet: true });
2893
+ } catch (packetError) {
2894
+ // Deliberately swallowed and named, not silent.
2895
+ console.error('Could not record the refused-stage verification packet: ' + packetError.message);
2896
+ }
2897
+ throw err;
2898
+ }
2850
2899
  response.changedFromWorkingTree = changedFromWorkingTree || [];
2851
2900
  response.changedFromWorkingTreeAvailable = changedFromWorkingTree !== null;
2852
2901
  response.requestedStageMode = stageMode;
2853
2902
  response.unrepresentableChanges = unstageable;
2903
+ if (response.success === false) {
2904
+ // A 200 that says `success:false`. The 422 path throws out of stageOrRefuse above and never lands
2905
+ // here, so this stays as the belt-and-braces case — print whatever detail came with it first.
2906
+ printCompileValidation(response.compileValidation, { stderr: true });
2907
+ throw new Error(response.message || 'Server stage failed');
2908
+ }
2854
2909
 
2855
2910
  await appendVerificationPacket(api, cwd, session, accountId, flags, {
2856
2911
  type: 'stage',
@@ -2901,16 +2956,71 @@ function normalizeEmptyWorksetPolicy(flags) {
2901
2956
  *
2902
2957
  * Returns [] when git could not answer, because "nothing is unrepresentable" would be a claim.
2903
2958
  */
2959
+ /**
2960
+ * The ONE identity a stage compares components by: type + id, or type + name for an id-less `new_` file.
2961
+ * The workset filter and the unrepresentable check used to spell this out separately.
2962
+ */
2963
+ function stageIdentityKey(entry) {
2964
+ return entry.type + ':' + (entry.id ? 'id:' + entry.id : 'name:' + String(entry.name || '').toLowerCase());
2965
+ }
2966
+
2967
+ /**
2968
+ * Name each git-changed component the way its payload is named.
2969
+ *
2970
+ * The changed set is built from FILENAMES; the payload takes `name` from the `.meta.yml`. For a `new_` file
2971
+ * there is no id, so `new_PDFStatement.groovy` + `name: PDF Statement` produced two identities for one
2972
+ * component: `--workset` filtered it out, it was reported as "no-local-files", and the platform (which
2973
+ * matches the changed set against the stored name) never marked it as workset — so a full snapshot staged
2974
+ * it WITHOUT compiling it. Both sides carry the file identity (`fileKey`), so align on that.
2975
+ */
2976
+ function alignChangedSetWithComponents(changed, components) {
2977
+ if (!Array.isArray(changed)) return changed;
2978
+ const byFileKey = new Map();
2979
+ (components || []).forEach((component) => {
2980
+ if (component && component.fileKey) byFileKey.set(component.fileKey, component);
2981
+ });
2982
+ return changed.map((entry) => {
2983
+ const component = entry && entry.fileKey ? byFileKey.get(entry.fileKey) : null;
2984
+ if (!component || entry.id || !component.name || component.name === entry.name) return entry;
2985
+ return withFileKey(Object.assign({}, entry, { name: component.name }), entry.fileKey);
2986
+ });
2987
+ }
2988
+
2989
+ /**
2990
+ * The local components, for identity alignment ONLY on paths that did not already collect them (the sync
2991
+ * gates). Best-effort: a sidecar that does not parse must not stop a sync the server is about to judge.
2992
+ */
2993
+ function collectComponentsForIdentity(cwd) {
2994
+ try {
2995
+ return collectComponents(cwd);
2996
+ } catch (_) {
2997
+ return [];
2998
+ }
2999
+ }
3000
+
2904
3001
  function unrepresentableChanges(changedFromWorkingTree, collected) {
2905
3002
  if (!Array.isArray(changedFromWorkingTree)) return [];
2906
- const present = new Set((collected || []).map((component) =>
2907
- component.type + ':' + (component.id ? 'id:' + component.id : 'name:' + String(component.name || '').toLowerCase())));
2908
- return changedFromWorkingTree
2909
- .filter((entry) => !present.has(entry.type + ':' + (entry.id ? 'id:' + entry.id : 'name:' + String(entry.name || '').toLowerCase())))
2910
- .map((entry) => Object.assign({}, entry, {
2911
- unrepresentable: true,
2912
- reason: (entry.statuses || []).some((s) => String(s).includes('D')) ? 'deleted' : 'no-local-files'
2913
- }));
3003
+ const contentFields = ['source', 'prompt', 'html', 'javascript', 'previewData', 'inputSchema', 'messages', 'schema'];
3004
+ const present = new Map((collected || []).map((component) => [stageIdentityKey(component), component]));
3005
+ const report = [];
3006
+ changedFromWorkingTree.forEach((entry) => {
3007
+ const deleted = (entry.statuses || []).some((s) => String(s).includes('D'));
3008
+ const component = present.get(stageIdentityKey(entry));
3009
+ if (!component) {
3010
+ report.push(Object.assign({}, entry, { unrepresentable: true, reason: deleted ? 'deleted' : 'no-local-files' }));
3011
+ return;
3012
+ }
3013
+ // The component is still present (its other files remain), so it IS staged — but a content file git
3014
+ // reports as deleted has nothing to send, and staged content fields LAYER: the lane keeps whatever it
3015
+ // held before. Reported alongside the component, which stays in the workset.
3016
+ const deletedFields = deleted
3017
+ ? (entry.fields || []).filter((field) => contentFields.includes(field) && component[field] == null)
3018
+ : [];
3019
+ if (deletedFields.length) {
3020
+ report.push(Object.assign({}, entry, { unrepresentable: true, reason: 'content-file-deleted', fields: deletedFields }));
3021
+ }
3022
+ });
3023
+ return report;
2914
3024
  }
2915
3025
 
2916
3026
  function printUnrepresentableChanges(unstageable) {
@@ -2918,12 +3028,23 @@ function printUnrepresentableChanges(unstageable) {
2918
3028
  console.log('');
2919
3029
  console.log('NOT REPRESENTABLE IN REDIS STAGING — ' + unstageable.length + ' change(s):');
2920
3030
  unstageable.slice(0, 20).forEach((entry) => {
3031
+ const fields = Array.isArray(entry.fields) && entry.reason === 'content-file-deleted' ? ': ' + entry.fields.join(', ') : '';
2921
3032
  console.log(' ' + entry.type + ' ' + (entry.id || entry.name || '(unknown)') +
2922
- ' (' + entry.reason + ') ' + (entry.paths || []).join(', '));
3033
+ ' (' + entry.reason + fields + ') ' + (entry.paths || []).join(', '));
2923
3034
  });
2924
3035
  console.log(' A staged entry cannot hide a component. Clearing it falls back to the committed row, so');
2925
3036
  console.log(' the component still resolves in a CLI-scoped run. Prove a deletion through the durable');
2926
3037
  console.log(' plan instead: remits-cli components sync --dry-run --summary --fail-on-errors');
3038
+ const partial = unstageable.filter((entry) => entry.reason === 'content-file-deleted');
3039
+ if (partial.length) {
3040
+ console.log(' A deleted CONTENT file of a component that is still present is not a staged removal: the');
3041
+ console.log(' lane keeps the content it already held, and compile validation judges THAT. Drop it with:');
3042
+ partial.slice(0, 5).forEach((entry) => {
3043
+ console.log(entry.id
3044
+ ? ' remits-cli components clear --component-type ' + entry.type + ' --component-id ' + entry.id
3045
+ : ' remits-cli components clear --all (' + entry.type + ' ' + entry.name + ' has no id; this clears only your lane)');
3046
+ });
3047
+ }
2927
3048
  }
2928
3049
 
2929
3050
  /**
@@ -2947,6 +3068,21 @@ async function stageOrRefuse(api, cwd, payload) {
2947
3068
  refusal.refused = true;
2948
3069
  throw refusal;
2949
3070
  }
3071
+ if (body && body.message) {
3072
+ // The server's `message` is ONE line derived from the FIRST failure. The rest of the detail is in
3073
+ // the body, and the caller cannot see the body — an agent handed "compile failed for Reader 41"
3074
+ // over a stage with nine broken components fixes one, re-stages, and pays nine round trips. Render
3075
+ // the whole list HERE, where the body is still in hand, then throw the one-line summary.
3076
+ printCompileValidation(body.compileValidation, { stderr: true });
3077
+ // A refusal can be ABOUT an unrepresentable change: a deleted source file leaves the lane holding the
3078
+ // content it had, and that is what was judged. Without this the refusal names a file that no longer
3079
+ // exists and "fix it and re-stage" cannot work. Read from the payload, which still carries `fields`.
3080
+ printUnrepresentableChanges((payload.changedSet || []).filter((entry) => entry && entry.unrepresentable));
3081
+ const failure = new Error(body.message);
3082
+ failure.responseBody = body;
3083
+ failure.status = status;
3084
+ throw failure;
3085
+ }
2950
3086
  throw err;
2951
3087
  }
2952
3088
  }
@@ -2967,6 +3103,67 @@ function printComponentPolicy(response) {
2967
3103
  (policy.overrides || []).forEach((id) => console.log('Policy override recorded for rule: ' + id));
2968
3104
  }
2969
3105
 
3106
+ /**
3107
+ * What the platform compiled, and what it could not.
3108
+ *
3109
+ * Prints on BOTH outcomes, including `attempted: 0`. A silent pass and a pass that validated nothing look
3110
+ * identical otherwise, and they are not the same claim: when git cannot identify the changed set the
3111
+ * server has no workset to validate and says so (`skipped[].reason`). Reporting that as silence would be
3112
+ * the same confident-wrong-number this surface exists to remove.
3113
+ */
3114
+ function printCompileValidation(validation, options) {
3115
+ // Failures go to stderr so `--json` stdout stays parseable: a caller that asked for JSON is a program,
3116
+ // and a refusal must not put prose in the middle of its document.
3117
+ const emit = (options && options.stderr) ? console.error : console.log;
3118
+ if (!validation || validation.attempted == null) return;
3119
+ const failures = Array.isArray(validation.failures) ? validation.failures : [];
3120
+ if (validation.attempted === 0) {
3121
+ const reasons = (Array.isArray(validation.skipped) ? validation.skipped : [])
3122
+ .map((entry) => (entry && entry.reason) || '')
3123
+ .filter((reason) => reason && reason !== 'no-compilable-source');
3124
+ if (reasons.length) {
3125
+ emit('Compile validation: NOT RUN — ' + reasons.join(', ') +
3126
+ '. Nothing in this stage was compile-checked.');
3127
+ if (reasons.includes('workset-unknown')) {
3128
+ emit(' git could not identify the changed set, so there was no workset to validate.');
3129
+ emit(' Stage from a git working tree, or re-stage the component explicitly, to get the check.');
3130
+ }
3131
+ }
3132
+ return;
3133
+ }
3134
+ const status = validation.success === true ? 'passed' : 'failed';
3135
+ emit('Compile validation: ' + status + ' — ' + validation.passed + '/' + validation.attempted +
3136
+ ' compiled in ' + validation.durationMs + 'ms' +
3137
+ (validation.concurrency ? ' (' + validation.concurrency + ' parallel)' : ''));
3138
+ // A skip is neither a pass nor a failure, and it used to be printed as a pass ("1/1 compiled" over a
3139
+ // component whose source was blank). Named, so the reader knows exactly what was NOT looked at.
3140
+ const unchecked = Array.isArray(validation.unchecked) ? validation.unchecked : [];
3141
+ if (unchecked.length) {
3142
+ emit(' ' + unchecked.length + ' NOT compile-checked: ' + unchecked.map((entry) =>
3143
+ (entry.type || 'component') + ' ' + (entry.id || entry.name || '?') + ' (' + (entry.reason || 'unchecked') + ')').join(', '));
3144
+ }
3145
+ const shown = failures.slice(0, COMPILE_FAILURES_SHOWN);
3146
+ shown.forEach((failure) => {
3147
+ emit(' ' + (failure.type || 'component') + ' ' + (failure.id || failure.name || '') +
3148
+ ': ' + (failure.message || 'compile failed'));
3149
+ });
3150
+ if (failures.length > shown.length) {
3151
+ // Named, not counted. "3 more failure(s)" sends an agent back for another round trip to learn WHICH
3152
+ // three; the identifiers are already in hand and cost one line.
3153
+ emit(' ' + (failures.length - shown.length) + ' more failed: ' +
3154
+ failures.slice(COMPILE_FAILURES_SHOWN)
3155
+ .map((failure) => (failure.type || 'component') + ' ' + (failure.id || failure.name || '?'))
3156
+ .join(', '));
3157
+ emit(' Re-run with --json for every compile message.');
3158
+ }
3159
+ if (validation.success !== true) {
3160
+ // The lane is written BEFORE it is validated, because the compile has to resolve through the staged
3161
+ // overlay to see the source it is judging. So a refusal does not mean "nothing was staged".
3162
+ emit(' The rejected source IS in the lane: staging writes, then compiles what it wrote.');
3163
+ emit(' A run in this lane will resolve the broken component until you fix it and re-stage.');
3164
+ }
3165
+ }
3166
+
2970
3167
  function shouldPrintFullComponentResponse(flags) {
2971
3168
  return flagEnabled(flags.verbose);
2972
3169
  }
@@ -3088,6 +3285,7 @@ function printStageSummary(response, flags) {
3088
3285
  if (overlay != null) {
3089
3286
  console.log('Materialized overlay now held by the lane:', overlay, 'component(s)');
3090
3287
  }
3288
+ printCompileValidation(response.compileValidation);
3091
3289
 
3092
3290
  // The trap `--changed-only` leaves behind: it merges, so a lane inherited from an earlier full snapshot
3093
3291
  // keeps every one of those entries resolving ahead of committed source. Silence here is what made
@@ -3584,7 +3782,9 @@ async function syncComponentsCommand(rawFlags) {
3584
3782
  const forceTombstones = flagEnabled(flags['force-tombstones']) || flagEnabled(flags.forceTombstones);
3585
3783
  const dryRun = flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun);
3586
3784
  const api = buildAxios(baseUrl, session.token);
3587
- const changedFromWorkingTree = changedComponentsFromWorkingTree(cwd);
3785
+ // Aligned for the same reason a stage is: the server's plan names a `new_` component by its sidecar
3786
+ // `name:`, and the changed set would otherwise name it by its filename and the gate would refuse it.
3787
+ const changedFromWorkingTree = alignChangedSetWithComponents(changedComponentsFromWorkingTree(cwd), collectComponentsForIdentity(cwd));
3588
3788
  const preflightRequested = syncPreflightRequested(flags);
3589
3789
  const namesOnly = flagEnabled(flags['names-only']) || flagEnabled(flags.namesOnly);
3590
3790
 
@@ -3992,7 +4192,7 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
3992
4192
 
3993
4193
  if (flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly)) {
3994
4194
  const since = flags['changed-since'] || flags.changedSince;
3995
- const fromRef = since ? changedComponentsSinceRef(cwd, String(since)) : null;
4195
+ const fromRef = since ? alignChangedSetWithComponents(changedComponentsSinceRef(cwd, String(since)), collectComponentsForIdentity(cwd)) : null;
3996
4196
  if (since && fromRef === null) {
3997
4197
  violations.push('--changed-since: could not diff against "' + since + '". Check the ref exists (git fetch first for a remote ref).');
3998
4198
  checks.changedOnly = false;
@@ -4501,7 +4701,22 @@ async function commitComponentsCommand(flags) {
4501
4701
  const landing = await acquireLandingLease(flags, accountId, branchName);
4502
4702
  try {
4503
4703
  if (!skipGit) {
4504
- console.log('Phase 1/3: local git commit/push');
4704
+ console.log('Phase 1/4: staged compile validation');
4705
+ // MERGE semantics on purpose (`--changed-only`, never `--workset`). A workset stage RECONCILES the
4706
+ // lane — it deletes every entry outside the git changed set — and the lane is frequently shared. A
4707
+ // validation pass must not be able to delete another agent's staged work as a side effect of somebody
4708
+ // running `components commit`. This adds the changed components to the lane and leaves the rest alone.
4709
+ await pushComponentsCommand(Object.assign({}, flags, {
4710
+ branch: branchName,
4711
+ 'account-id': accountId,
4712
+ mode: 'stage',
4713
+ workset: false,
4714
+ 'replace-lane': false,
4715
+ replaceLane: false,
4716
+ 'changed-only': true
4717
+ }));
4718
+
4719
+ console.log('Phase 2/4: local git commit/push');
4505
4720
  const status = runGit(cwd, 'git status --porcelain');
4506
4721
  if (status || allowEmpty) {
4507
4722
  runGit(cwd, 'git add -A');
@@ -4528,14 +4743,12 @@ async function commitComponentsCommand(flags) {
4528
4743
  console.log('Skipping local git phase. Prefer `remits-cli components sync` if you only need the server sync step.');
4529
4744
  }
4530
4745
 
4531
- console.log('Phase 2/3: server sync');
4746
+ console.log('Phase 3/4: server sync');
4532
4747
  if (flagEnabled(flags.safe)) {
4533
- // The honest limit, stated where it applies. `components commit` runs git add/commit/push BEFORE the
4534
- // server sync, so a --safe gate here cannot stop the push — it stops the PLATFORM from writing bad
4535
- // overlays. The branch may already be pushed when the gate refuses; that is recoverable, an
4536
- // unintended ComponentVariant tree is much less so.
4537
- console.log(' --safe: the branch is already pushed. The gate below stops the platform from WRITING a');
4538
- console.log(' surprising plan; it cannot un-push. Use `components sync --safe` alone for a pre-push gate.');
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.');
4539
4752
  }
4540
4753
  const syncResponse = await syncComponentsCommand({
4541
4754
  ...flags,
@@ -4544,7 +4757,7 @@ async function commitComponentsCommand(flags) {
4544
4757
  });
4545
4758
 
4546
4759
  if (!skipGit) {
4547
- console.log('Phase 3/3: local fast-forward pull');
4760
+ console.log('Phase 4/4: local fast-forward pull');
4548
4761
  runGit(cwd, 'git fetch origin ' + shellQuote(branchName));
4549
4762
  const expectedSha = syncResponse && syncResponse.sync && syncResponse.sync.postSyncSha;
4550
4763
  if (expectedSha) {
@@ -11144,13 +11357,13 @@ function printComponentsHelp(subcommand) {
11144
11357
  if (subcommand === 'commit') {
11145
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]');
11146
11359
  console.log('');
11147
- console.log('Runs local git add/commit/push, then server sync. Prefer explicit git + components sync when you need inspectable phases.');
11360
+ console.log('Runs staged compile validation, local git add/commit/push, server sync, then fast-forward pull.');
11361
+ console.log('Prefer explicit stage + git + components sync when you need inspectable phases.');
11148
11362
  console.log('components commit does not support --dry-run.');
11149
11363
  console.log('');
11150
- console.log('--safe passes the sync gates through to phase 2. The honest limit: git push happens in phase 1,');
11151
- console.log('so the gate stops the PLATFORM from writing a surprising plan — it cannot un-push the branch.');
11152
- console.log('For a gate that runs before anything leaves your machine, push yourself and use');
11153
- console.log('`remits-cli components sync --safe`.');
11364
+ console.log('Phase 1 merge-stages the git-changed components and compile-validates their runtime source.');
11365
+ console.log('It uses --changed-only semantics, so it never reconciles a lane you share with another agent.');
11366
+ console.log('--safe also gates the server sync plan before any trunk rows or ComponentVariant overlays are written.');
11154
11367
  return;
11155
11368
  }
11156
11369
  console.log('Usage: remits-cli components <stage|status|lanes|entries|clear|sync|commit|promotion|branches|branch>');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remits/remits-cli",
3
- "version": "0.1.121",
3
+ "version": "0.1.124",
4
4
  "description": "Local CLI for auth, component sync, and live test execution against Remits",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -60,6 +60,12 @@ reference named after it.
60
60
  - **edit → stage → run, every time.** The platform executes whatever is in the staging cache at the
61
61
  moment a run starts. Edit a file, run a test without `remits-cli components stage`, and the test runs
62
62
  the OLD code. This is the single most common mistake. (`development-loop.md`)
63
+ - **Staging and sync fail early on broken runtime-compiled Groovy.** Stage validates changed source in the
64
+ active lane; sync validates changed/new compiled components before writing DB rows or branch variants.
65
+ Agent files are Utility components internally. A stage refused BY COMPILE VALIDATION still wrote the
66
+ lane — the compile has to resolve through the staged overlay to see the source it is judging — so fix and
67
+ re-stage before running anything in that lane. (A policy or edit-lease refusal is the opposite: it
68
+ returns before any write.) (`development-loop.md`, `component-integrity.md`, `branch-variants.md`)
63
69
  - **Stage your WORKSET, not the whole repo: `remits-cli components stage --workset`.** It uploads only
64
70
  the components git reports changed and makes the lane hold exactly them. A plain `components stage` is
65
71
  a FULL SNAPSHOT — it puts every component in the repo into the lane, so "115 staged" tells a human
@@ -222,6 +222,12 @@ remits-cli components sync --dry-run # inspect overrides/additions/tombstones wi
222
222
  remits-cli components sync # writes ComponentVariant overlays ONLY
223
223
  ```
224
224
 
225
+ `components stage` validates changed runtime-compiled source through the active branch/workspace lane,
226
+ and `components sync` validates changed/new Groovy source before writing a `ComponentVariant`. This is
227
+ intentional: a branch overlay that cannot compile should fail at staging/sync time, not later when the
228
+ subscriber's workflow first resolves it. `Agent` files in `components/agents/` are the `Utility`
229
+ component kind internally; the CLI and validator normalize both names to the same target.
230
+
225
231
  Promotion back to trunk is a **git** operation followed by a **trunk** sync — the platform merges nothing
226
232
  for you, and merging a branch promotes its **deletions** as hard deletes. Do not improvise it: follow
227
233
  `features/subscriber-branch-promotions.md`. For a real trunk promotion with many `new_` files, use
@@ -330,7 +336,8 @@ The analogous hazard is different, and you must still respect it:
330
336
  `removed`, `unchanged`, `skipped`, and `errors` without writing rows, caching the sync SHA, or clearing
331
337
  staging. Existing overlay ids appear as `variantId`; an `unchanged` row with `pruned:true` means the
332
338
  branch has converged back to trunk and the overlay would be removed. It is rejected on trunk, and
333
- `components commit --dry-run` is unsupported because `commit` performs local git writes before syncing.
339
+ `components commit --dry-run` is unsupported because `commit` performs compile validation and local git
340
+ writes before syncing.
334
341
  - **The sync refuses a wholesale removal.** Above roughly a third of a kind — or **100% of a kind at any
335
342
  size** — it aborts that kind, reports why, and points at a rebase. Rebasing is almost always the real
336
343
  fix. Only when the removals are genuinely deliberate, re-run with `--force-tombstones`.
@@ -198,7 +198,7 @@ remits-cli components lanes [--json] # every indexed sta
198
198
  remits-cli components entries --lane-id <id> [--json|--verbose] # authoritative staged files for one lane
199
199
  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
200
200
  remits-cli components sync [--safe [--yes]] [--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
201
- remits-cli components commit [--safe] [--message "msg"] [--data-mode test|prod] [--force-tombstones] # --safe gates phase 2; it cannot un-push phase 1
201
+ remits-cli components commit [--safe] [--message "msg"] [--data-mode test|prod] [--force-tombstones] # phase 1 merge-stages + compile-validates changed source (never reconciles the lane); --safe gates sync writes
202
202
  remits-cli components branches [--json] # branches carrying committed variants, with counts + drift
203
203
  remits-cli components branch <name> [--json] # one branch: overridden / added / removed, drift flags, subscribers
204
204
  remits-cli components branch <name> --diff <componentId> --component-type <kind> [--json]
@@ -87,21 +87,41 @@ every live component present at its real id, and nothing extra.
87
87
 
88
88
  | Command | What it touches | Danger |
89
89
  |---|---|---|
90
- | `remits-cli components stage` (alias: deprecated `push`) | **Redis staging cache only.** Never mutates the DB or git. The safe iteration surface. | none |
90
+ | `remits-cli components stage` (alias: deprecated `push`) | **Redis staging cache only.** Never mutates the DB or git. The safe iteration surface. A plain stage or `--workset` RECONCILES the lane (entries outside the manifest are dropped); `--changed-only` merges. | none for the DB; a lane you share with another agent is reconciled by the first two |
91
91
  | `remits-cli components status` | Reads this lane's staging scope (account/user/branch/workspace) and branch resolution, and lists every other lane on the branch. | none |
92
92
  | `remits-cli components clear` | Clears THIS lane's staging cache without changing DB or git. Never touches another workspace lane. | none |
93
93
  | `remits-cli components sync` **on trunk** | **Server-side git→DB reconcile of the whole account** (create/update/**delete**/rename). Reads the pushed remote; ignores local files. | **high** |
94
94
  | `remits-cli components sync` **on a variant branch** | Writes `ComponentVariant` overlays for that branch only. Never touches trunk rows or the account's trunk branch. When the checkout identifies a subscribing account, the branch-local `account-info.json` is refreshed for that subscriber; `--dry-run` reports the plan without writes. | medium (a missing file becomes a **tombstone** that hides the component from subscribers) |
95
- | `remits-cli components commit` | **One shot:** `git add -A` + commit + `git push` + **`components sync`** + `git pull --ff-only`. Blindly stages the *entire* working tree (including any drift) and reconciles it into prod. Inherits the danger of whichever sync mode the branch selects. | **highest on trunk** |
95
+ | `remits-cli components commit` | **One shot:** changed-source compile validation (a `--changed-only` MERGE stage, so it never reconciles the lane) + `git add -A` + commit + `git push` + **`components sync`** + `git pull --ff-only`. Blindly stages the *entire* working tree (including any drift) and reconciles it into prod. Inherits the danger of whichever sync mode the branch selects. | **highest on trunk** |
96
96
 
97
97
  Key implications:
98
- - **`stage` is always safe** — stage and test as much as you want; it never reconciles or deletes.
99
- - **`components commit` is the most dangerous command**, not a mere convenience wrapper: it `git add -A`
100
- commits and pushes whatever is in the working tree, then immediately syncs. Never run it while the tree
101
- contains drift or unexplained changes. Prefer the explicit, observable
102
- `git commit → git push → components sync → git pull` sequence so each phase can be inspected.
98
+ - **`stage` never touches the DB or git** — stage and test as much as you want. It is safe *for your
99
+ components*; it is not inert *for the lane*. A full snapshot and `--workset` both reconcile the lane to
100
+ their manifest, so on a lane shared with another agent they drop that agent's staged entries. Use
101
+ `--changed-only` when you mean "add mine, leave theirs".
102
+ - **A COMPILE-VALIDATION refusal still wrote the lane; a policy refusal does not.** Read the status code,
103
+ because the two refusals leave opposite states behind:
104
+ - **422 (compile validation)** — the entries are already stored. Validation runs after the write because
105
+ the compile has to resolve through the staged overlay to see the source it is judging. The response
106
+ says so with `laneHoldsRejectedSource`. Until you fix the source and re-stage, a run in that lane
107
+ resolves the broken component.
108
+ - **409 (account policy, or another agent's edit lease)** — refused before anything was written. The lane
109
+ is exactly as it was; nothing to undo.
110
+ - **"Compile validation: NOT RUN" is a real answer.** The check keys off the git changed set. If git
111
+ cannot identify one — you are not in a working tree, or git failed — there is no workset to validate and
112
+ the CLI says `workset-unknown` rather than implying a pass.
113
+ - **`components commit` is the most dangerous command**, not a mere convenience wrapper: it first
114
+ merge-stages and compile-validates the changed runtime source, then `git add -A`, commits, pushes, and
115
+ immediately syncs. Never run it while the tree contains drift or unexplained changes. Prefer the explicit,
116
+ observable `components stage → git commit → git push → components sync → git pull` sequence so each
117
+ phase can be inspected.
103
118
  - **`components sync` acts on the pushed remote**, so local edits are invisible to it until committed **and
104
119
  pushed**, and a drifted **remote** is dangerous even when your local tree looks fine.
120
+ - **Runtime-compiled component source is validated before durable writes.** When a trunk sync or variant
121
+ sync sees changed/new Groovy source for a `Reader`, `Action`, `Embeddable`, `HtmlTemplate`, `Rule`,
122
+ `Test`, `Agent`/`Utility`, or `Tool`, the platform compiles it before accepting the DB row or
123
+ `ComponentVariant` overlay. A compile failure lands in `syncResults.errors` and the branch SHA cache is
124
+ not advanced, so fix the source and retry the same sync.
105
125
  - After a successful non-dry-run `components sync` / `components commit`, the server clears the full
106
126
  staging scope for that lane (account/user/branch/workspace). This is the expected clean state: old Redis aliases should not keep shadowing
107
127
  the newly synced DB rows. `components sync --dry-run` intentionally leaves staging untouched.
@@ -218,6 +218,33 @@ remits-cli components stage --workset # every edit: stage what you changed
218
218
 
219
219
  This uploads your local component changes to the platform's staging cache (Redis, 240-minute TTL). It does NOT commit anything. The platform cannot see your local edits until you stage them.
220
220
 
221
+ For runtime-compiled components (`Reader`, `Action`, `Embeddable`, `HtmlTemplate`, `Rule`, `Test`,
222
+ `Agent`/`Utility`, and `Tool`), staging also validates the Groovy source that is part of the submitted
223
+ workset. Validation resolves through the same branch/workspace staging lane a later run will use and
224
+ compiles candidates in a small bounded pool, so a syntax/compile error is reported by `components stage`
225
+ instead of waiting for the next workflow to trip over it. A full repository stage does not compile every
226
+ component in the repo; it validates only entries known to be in the current workset, plus explicit partial
227
+ source updates.
228
+
229
+ Two things to read correctly when it refuses:
230
+
231
+ - **The lane already holds what it rejected — on a 422.** The compile has to resolve through the staged
232
+ overlay to see the source it is judging, so the entries are written first and compiled second. `422`
233
+ means "staged, and rejected", not "nothing happened" — fix the source and re-stage before running
234
+ anything in that lane. A `409` is the other kind of refusal (account policy, or another agent's edit
235
+ lease) and writes nothing at all.
236
+ - **Every failure is printed, not just the first.** The one-line error is the first failure; the full list
237
+ (identified by type and id) follows it. Fix them in one pass rather than one round trip each.
238
+ - **`N NOT compile-checked` is not a pass.** A `new_` component whose source file is empty or blank cannot
239
+ be judged, so it is listed by name with its reason instead of being counted as compiled. It will not run.
240
+ - **Deleting a component's source file while its sidecar stays is `content-file-deleted`.** The lane keeps
241
+ the source it already held, and that is what gets compiled and run. Clear that one component with the
242
+ command the CLI prints (`components clear --component-type <type> --component-id <id>`).
243
+
244
+ If git cannot identify the changed set — you are not in a working tree, or git failed — there is no
245
+ workset to validate, and the CLI prints `Compile validation: NOT RUN — workset-unknown` rather than
246
+ silently implying a pass.
247
+
221
248
  ##### Stage your workset, not the whole repo
222
249
 
223
250
  There are three stage modes, and the difference decides what a run in your lane resolves and what a