@remits/remits-cli 0.1.114 → 0.1.116

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
@@ -141,6 +141,10 @@ function hasExplicitDataModeFlag(flags) {
141
141
  );
142
142
  }
143
143
 
144
+ function dataModeFlagSource(flags) {
145
+ return hasExplicitDataModeFlag(flags) ? 'explicitFlag' : 'cliDefault';
146
+ }
147
+
144
148
  function canonicalSessionKey(accountId, baseUrl, dataMode) {
145
149
  const normalizedAccountId = Number(accountId);
146
150
  const normalizedBaseUrl = normalizeBaseUrl(baseUrl || DEFAULT_BASE_URL);
@@ -347,6 +351,7 @@ function resolveSessionIdentity(cwd, flags = {}) {
347
351
  // an account whose session is parked on prod cannot have a test run silently follow it. Report both
348
352
  // rather than a single number that governs only some commands.
349
353
  testRunDataMode: requestedDataMode || DEFAULT_DATA_MODE,
354
+ testRunDataModeSource: dataModeFlagSource(flags),
350
355
  baseUrl: normalizeBaseUrl((session && session.baseUrl) || requestedBaseUrl || DEFAULT_BASE_URL),
351
356
  updatedAt: session ? session.updatedAt : null,
352
357
  sessionResolutionWarning
@@ -366,7 +371,8 @@ function printSessionIdentity(identity, options = {}) {
366
371
  console.log('Branch:', identity.branchName || 'unresolved');
367
372
  console.log('Workspace:', identity.workspace || 'none (shared default lane)');
368
373
  console.log('Data mode:', (identity.dataMode || DEFAULT_DATA_MODE) + ' (applies to tool/tools/token)');
369
- console.log('Data mode (test run):', identity.testRunDataMode || DEFAULT_DATA_MODE);
374
+ console.log('Data mode (test run):', identity.testRunDataMode || DEFAULT_DATA_MODE,
375
+ '[' + (identity.testRunDataModeSource || 'cliDefault') + ']');
370
376
  console.log('Base URL:', identity.baseUrl);
371
377
  return;
372
378
  }
@@ -396,6 +402,7 @@ function printSessionIdentity(identity, options = {}) {
396
402
  // prod would otherwise read as "prod" here while the next test run went to the test lane. Say so rather
397
403
  // than letting the tuple imply a lane it does not govern.
398
404
  console.log(' Data mode (test run):', identity.testRunDataMode,
405
+ '[' + (identity.testRunDataModeSource || 'cliDefault') + ']',
399
406
  identity.testRunDataMode === identity.dataMode ? '' : '— test run ignores the session lane; pass --data-mode prod to override');
400
407
  console.log(' Base URL:', identity.baseUrl);
401
408
  if (identity.updatedAt) {
@@ -767,14 +774,40 @@ function sha256(value) {
767
774
  return crypto.createHash('sha256').update(value).digest('hex');
768
775
  }
769
776
 
777
+ /**
778
+ * The branch this directory is on.
779
+ *
780
+ * <p><b>Git is asked FIRST, and the environment override is only a fallback.</b> It used to be the other
781
+ * way round, which made `REMITS_GIT_BRANCH` able to lie about a real branch: this value keys the component
782
+ * staging scope, the landing lease, the run context and every promotion command, so a stale export in a
783
+ * shell silently mislabelled every one of them — staging into a lane the tree is not on, and taking the
784
+ * landing lease for a branch nobody is landing. Nothing warned, because both halves looked correct.</p>
785
+ *
786
+ * <p>The override exists for the one case where git genuinely cannot answer: a DETACHED worktree, where
787
+ * `rev-parse --abbrev-ref HEAD` returns the literal `HEAD`. A ticket worker that detaches declares the
788
+ * branch it is working, and that declaration is used only where there is no real answer to contradict.
789
+ * So the legitimate use is preserved and the footgun is gone — on a normal checkout the variable now
790
+ * cannot change what anything reports.</p>
791
+ */
770
792
  function currentBranch(cwd) {
771
793
  try {
772
- return execSync('git rev-parse --abbrev-ref HEAD', { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
794
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
795
+ // `HEAD` means detached: git has no branch name to give, so a declared one is the only answer.
796
+ if (branch && branch !== 'HEAD') return branch;
797
+ return remitsGitBranchOverride() || branch;
773
798
  } catch (err) {
799
+ const override = remitsGitBranchOverride();
800
+ if (override) return override;
774
801
  return 'main';
775
802
  }
776
803
  }
777
804
 
805
+ function remitsGitBranchOverride() {
806
+ const value = process.env.REMITS_GIT_BRANCH || process.env.REMITS_BRANCH_NAME;
807
+ const branch = value == null ? '' : String(value).trim();
808
+ return branch && branch !== 'HEAD' ? branch : null;
809
+ }
810
+
778
811
  function shellQuote(value) {
779
812
  return '\'' + String(value).replace(/'/g, '\'\"\'\"\'' ) + '\'';
780
813
  }
@@ -2425,12 +2458,29 @@ async function pushComponentsCommand(flags) {
2425
2458
  const mode = requestedMode === 'push' ? 'stage' : requestedMode;
2426
2459
  const changedFromWorkingTree = changedComponentsFromWorkingTree(cwd);
2427
2460
 
2428
- // --changed-only stages just the components this working tree edited, instead of re-uploading all of
2429
- // them. Two reasons it matters here: it is what makes an iteration loop cheap on a large repo, and it
2430
- // deliberately does NOT send `replace`, so the server's reconcile pass cannot delete the entries for
2431
- // components this partial stage did not mention.
2432
- const changedOnly = flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly);
2461
+ // WHAT this stage is. Three shapes, and the difference between them is the difference between a lane
2462
+ // that reads as "7 components in flight" and one that reads as "115 staged":
2463
+ //
2464
+ // full snapshot the whole repo manifest, lane reconciled to it `components stage`
2465
+ // workset replace only the git-changed components, lane reconciled `components stage --workset`
2466
+ // workset merge only the git-changed components, merged into the lane `components stage --changed-only`
2467
+ //
2468
+ // `--changed-only` keeps its old merge semantics deliberately — changing what an existing flag does
2469
+ // silently is how a safe command becomes a destructive one for somebody's script. The exact-lane
2470
+ // behavior is the new flag.
2471
+ const worksetReplace = flagEnabled(flags.workset) ||
2472
+ ((flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly)) &&
2473
+ (flagEnabled(flags['replace-lane']) || flagEnabled(flags.replaceLane)));
2474
+ const changedOnly = worksetReplace || flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly);
2475
+ const stageMode = worksetReplace ? 'workset-replace' : (changedOnly ? 'workset-merge' : 'full-snapshot');
2476
+ const emptyWorksetPolicy = normalizeEmptyWorksetPolicy(flags);
2477
+
2433
2478
  let components = collectComponents(cwd);
2479
+ // Changes git reported that a component payload cannot carry — a deleted component file has nothing to
2480
+ // stage, and Redis staging has no way to say "hide this during CLI-scoped runs". Removing its staged
2481
+ // entry falls back to the committed row, so the component still resolves. Reported out loud rather than
2482
+ // dropped, because "my delete did not take effect" otherwise looks like a platform bug.
2483
+ const unstageable = unrepresentableChanges(changedFromWorkingTree, components);
2434
2484
  if (changedOnly) {
2435
2485
  if (changedFromWorkingTree === null) {
2436
2486
  throw new Error('--changed-only needs a git working tree to establish the changed set, and this directory is not one.');
@@ -2439,10 +2489,20 @@ async function pushComponentsCommand(flags) {
2439
2489
  entry.type + ':' + (entry.id ? 'id:' + entry.id : 'name:' + String(entry.name || '').toLowerCase())));
2440
2490
  components = components.filter((component) => wanted.has(
2441
2491
  component.type + ':' + (component.id ? 'id:' + component.id : 'name:' + String(component.name || '').toLowerCase())));
2442
- if (!components.length) {
2443
- console.log('--changed-only: this working tree has no uncommitted component edits; nothing to stage.');
2492
+ if (!components.length && !(worksetReplace && emptyWorksetPolicy === 'clear')) {
2493
+ // Deliberately NOT an error, and deliberately not a clear. An agent that has not edited anything yet
2494
+ // is in an ordinary state; failing its loop teaches it nothing, and reconciling the lane to an empty
2495
+ // manifest would delete the overlay its next test run depends on.
2496
+ console.log('Stage mode:', stageMode);
2497
+ console.log('Workset detected from git: 0 component(s) — nothing was staged and the lane was left as it is.');
2498
+ if (worksetReplace) {
2499
+ console.log(' The lane was NOT cleared. Clearing stays explicit: `remits-cli components clear --all`,');
2500
+ console.log(' or re-run with `--empty-workset clear` if an empty lane is what you meant.');
2501
+ }
2444
2502
  printStagingLane(branchName, workspace, workspaceSource(cwd, flags));
2445
- return { success: true, mode, updated: 0, unchanged: 0, skipped: [], changedOnly: true };
2503
+ printUnrepresentableChanges(unstageable);
2504
+ return { success: true, mode, updated: 0, unchanged: 0, skipped: [], changedOnly: true, stageMode,
2505
+ worksetCount: 0, staged: false };
2446
2506
  }
2447
2507
  }
2448
2508
 
@@ -2457,20 +2517,40 @@ async function pushComponentsCommand(flags) {
2457
2517
  // or, worse, conclude staging was broken and go on reading trunk. Scaled by payload size, floored at
2458
2518
  // the old default, and overridable.
2459
2519
  const api = buildAxios(baseUrl, session.token, stageTimeoutMs(components, flags));
2460
- const response = await loggedPost(api, cwd, '/cli/components', {
2520
+ const response = await stageOrRefuse(api, cwd, {
2461
2521
  token: session.token,
2462
2522
  accountId,
2463
2523
  branchName,
2464
2524
  workspace,
2465
2525
  dataMode,
2466
2526
  mode,
2467
- // A partial (--changed-only) stage must not trigger the server's reconcile pass: reconcile deletes
2468
- // every staged entry absent from the manifest, which for a partial manifest is nearly all of them.
2527
+ // WHERE this stage is coming from. The platform derives a lightweight presence record from it, so an
2528
+ // interactive agent that never ran `agent register` is still visible to every headless worker in the
2529
+ // fleet — and so the edit-lease check below can tell "somebody else holds it" from "I hold it".
2530
+ ...runContextPayload(flags),
2531
+ agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined,
2532
+ acknowledge: flags.acknowledge || undefined,
2533
+ // What this stage IS, stated rather than implied. The server derives the lane behavior from the mode
2534
+ // (CliComponentStaging.stageRequest) instead of from `replace`, which was overloaded: a partial
2535
+ // manifest with `replace: true` would make the reconcile pass delete nearly the whole lane, so
2536
+ // `--changed-only` had to send `replace: false` and could therefore never shrink a lane it inherited.
2537
+ stageMode,
2538
+ manifestScope: changedOnly ? 'working-tree' : 'repo',
2539
+ emptyWorksetPolicy,
2540
+ // Whether git could answer at all. A zero rendered for an unavailable answer is exactly the kind of
2541
+ // confident wrong number this whole surface exists to remove.
2542
+ worksetKnown: changedFromWorkingTree !== null,
2543
+ // The git evidence, per component: which files changed and with what status. It is what lets the
2544
+ // console split a lane's "active workset" from the entries a previous full snapshot left behind.
2545
+ changedSet: (changedFromWorkingTree || []).concat(unstageable),
2546
+ // Kept for a platform that predates stageMode. Same meaning it always had.
2469
2547
  replace: !changedOnly,
2470
2548
  components
2471
- }).then((r) => r.data);
2549
+ });
2472
2550
  response.changedFromWorkingTree = changedFromWorkingTree || [];
2473
2551
  response.changedFromWorkingTreeAvailable = changedFromWorkingTree !== null;
2552
+ response.requestedStageMode = stageMode;
2553
+ response.unrepresentableChanges = unstageable;
2474
2554
 
2475
2555
  if (flagEnabled(flags.json)) {
2476
2556
  console.log(JSON.stringify(response, null, 2));
@@ -2481,10 +2561,94 @@ async function pushComponentsCommand(flags) {
2481
2561
  printResolvedBaseUrl(baseUrl);
2482
2562
  printStagingLane(response.branchName || branchName, response.workspace || workspace, workspaceSource(cwd, flags));
2483
2563
  console.log('Data mode:', response.dataMode || dataMode);
2484
- console.log('Mode:', response.mode || mode + (changedOnly ? ' (--changed-only)' : ''));
2564
+ printComponentPolicy(response);
2485
2565
  printStageSummary(response, flags);
2486
2566
  }
2487
2567
 
2568
+ /** `--empty-workset refuse|clear`. Refusing is the default, and the only safe one. */
2569
+ function normalizeEmptyWorksetPolicy(flags) {
2570
+ const raw = flags['empty-workset'] != null ? flags['empty-workset'] : flags.emptyWorkset;
2571
+ const value = raw === true ? '' : String(raw == null ? '' : raw).trim().toLowerCase();
2572
+ if (value === 'clear' || value === 'clears-lane') return 'clear';
2573
+ return 'refuse';
2574
+ }
2575
+
2576
+ /**
2577
+ * Git-reported component changes that a stage payload cannot carry.
2578
+ *
2579
+ * A deleted component file has no content to send, and Redis staging has no way to express "hide this
2580
+ * during CLI-scoped runs" — clearing its staged entry falls back to the committed row, so the component
2581
+ * still resolves. An agent that deletes a file, stages, and sees the component still running would read
2582
+ * that as a platform defect. Durable deletion lives in variant sync (tombstones), not here.
2583
+ *
2584
+ * Returns [] when git could not answer, because "nothing is unrepresentable" would be a claim.
2585
+ */
2586
+ function unrepresentableChanges(changedFromWorkingTree, collected) {
2587
+ if (!Array.isArray(changedFromWorkingTree)) return [];
2588
+ const present = new Set((collected || []).map((component) =>
2589
+ component.type + ':' + (component.id ? 'id:' + component.id : 'name:' + String(component.name || '').toLowerCase())));
2590
+ return changedFromWorkingTree
2591
+ .filter((entry) => !present.has(entry.type + ':' + (entry.id ? 'id:' + entry.id : 'name:' + String(entry.name || '').toLowerCase())))
2592
+ .map((entry) => Object.assign({}, entry, {
2593
+ unrepresentable: true,
2594
+ reason: (entry.statuses || []).some((s) => String(s).includes('D')) ? 'deleted' : 'no-local-files'
2595
+ }));
2596
+ }
2597
+
2598
+ function printUnrepresentableChanges(unstageable) {
2599
+ if (!unstageable || !unstageable.length) return;
2600
+ console.log('');
2601
+ console.log('NOT REPRESENTABLE IN REDIS STAGING — ' + unstageable.length + ' change(s):');
2602
+ unstageable.slice(0, 20).forEach((entry) => {
2603
+ console.log(' ' + entry.type + ' ' + (entry.id || entry.name || '(unknown)') +
2604
+ ' (' + entry.reason + ') ' + (entry.paths || []).join(', '));
2605
+ });
2606
+ console.log(' A staged entry cannot hide a component. Clearing it falls back to the committed row, so');
2607
+ console.log(' the component still resolves in a CLI-scoped run. Prove a deletion through the durable');
2608
+ console.log(' plan instead: remits-cli components sync --dry-run --summary --fail-on-errors');
2609
+ }
2610
+
2611
+ /**
2612
+ * A policy refusal is an ANSWER, not a transport failure.
2613
+ *
2614
+ * The platform answers 409 with the sentence to act on ("another agent holds the edit lease on this
2615
+ * repository and you are staging into the SHARED lane…"). That sentence must reach the caller intact —
2616
+ * an axios stack trace teaches an agent nothing and it will retry the identical call.
2617
+ */
2618
+ async function stageOrRefuse(api, cwd, payload) {
2619
+ try {
2620
+ return await loggedPost(api, cwd, '/cli/components', payload).then((r) => r.data);
2621
+ } catch (err) {
2622
+ const body = err && err.response && err.response.data;
2623
+ const status = err && err.response && err.response.status;
2624
+ if (status === 409 && body && body.message) {
2625
+ if (body.editLease) {
2626
+ console.error('Edit lease: ' + describeRunLocation(body.editLease));
2627
+ }
2628
+ const refusal = new Error(body.message);
2629
+ refusal.refused = true;
2630
+ throw refusal;
2631
+ }
2632
+ throw err;
2633
+ }
2634
+ }
2635
+
2636
+ /** What the account's rules said about this stage or commit — including "they could not be read". */
2637
+ function printComponentPolicy(response) {
2638
+ const policy = response && response.policy;
2639
+ const lease = response && response.editLease;
2640
+ if (lease) {
2641
+ console.log('Note: another agent holds the edit lease on this repository — ' +
2642
+ (describeRunLocation(lease) || 'location not recorded') + '.');
2643
+ }
2644
+ if (!policy) return;
2645
+ if (policy.error) {
2646
+ console.error('POLICY NOT ENFORCED: this account has component rules that could not be parsed: ' + policy.error);
2647
+ }
2648
+ (policy.warnings || []).forEach((warning) => console.log('Policy warning: ' + warning));
2649
+ (policy.overrides || []).forEach((id) => console.log('Policy override recorded for rule: ' + id));
2650
+ }
2651
+
2488
2652
  function shouldPrintFullComponentResponse(flags) {
2489
2653
  return flagEnabled(flags.verbose);
2490
2654
  }
@@ -2521,15 +2685,55 @@ function printComponentCommandResponse(label, response, flags) {
2521
2685
  printFullResponseHint();
2522
2686
  }
2523
2687
 
2688
+ /**
2689
+ * What this stage did, in the terms a human is actually asking about.
2690
+ *
2691
+ * Three numbers, and they are three different questions:
2692
+ *
2693
+ * WORKSET how many components this working tree changed, per git
2694
+ * SUBMITTED how many this command uploaded
2695
+ * OVERLAY how many staged entries the lane now holds, which is what a run resolves
2696
+ *
2697
+ * They used to be reported as one number — the overlay — so an agent that edited 5 components in a repo
2698
+ * of 115 produced "Updated: 115" and a console badge reading "STAGED 115". Nobody could tell 115 edits
2699
+ * from one full snapshot, and those want opposite actions.
2700
+ */
2524
2701
  function printStageSummary(response, flags) {
2525
- // `stage` always uploads EVERY component in the repo, so the server's Updated/Unchanged counts
2526
- // describe the whole staging overlay, not what this checkout edited. That is what made "Updated: 75"
2527
- // read as "I changed 75 components". Lead with the working-tree delta, which is the number an agent
2528
- // is actually asking about, and label the server counts as the overlay they describe.
2529
2702
  const changed = Array.isArray(response.changedFromWorkingTree) ? response.changedFromWorkingTree : [];
2530
2703
  const tracked = response.changedFromWorkingTreeAvailable !== false;
2704
+ const stage = response.stage || {};
2705
+ const lane = response.laneSummary || {};
2706
+ // What we ASKED for, and what the platform says it did. Kept apart deliberately: comparing the server's
2707
+ // answer against a value that already fell back to it can never disagree, so the mismatch note below
2708
+ // could never fire.
2709
+ const requestedMode = response.requestedStageMode || null;
2710
+ const stageMode = stage.mode || requestedMode || 'full-snapshot';
2711
+
2712
+ console.log('Stage mode:', describeStageMode(stageMode));
2713
+
2714
+ // A platform that predates stage modes ignores `stageMode` and reads `replace`, which a workset stage
2715
+ // sends as false — so it MERGES where the caller asked for a lane replacement, and returns no `stage`
2716
+ // block to say so. Silence there is the worst outcome available: the agent believes the lane holds
2717
+ // exactly its workset while the platform left an inherited overlay in place. Detect it by what came
2718
+ // back rather than by a version number.
2719
+ if (stage.mode == null && response.staging) {
2720
+ console.log('NOTE: this platform predates stage modes. It read the legacy `replace` flag, so a');
2721
+ console.log(' --workset stage MERGED into the lane instead of reconciling it, and the workset/');
2722
+ console.log(' overlay split below is not available. Use `components clear --all` first if you need');
2723
+ console.log(' the lane to hold exactly your workset.');
2724
+ } else if (stage.mode && requestedMode && stage.mode !== requestedMode) {
2725
+ console.log('NOTE: asked the platform for `' + requestedMode + '`; it applied `' + stage.mode + '`.');
2726
+ }
2727
+
2728
+ if (response.sharedLane === true) {
2729
+ console.log('Lane: SHARED (no workspace) — a full stage REPLACES it rather than merging, so a second');
2730
+ console.log(' agent staging here overwrites what you are testing. `remits-cli workspace use --auto`.');
2731
+ }
2732
+
2733
+ // "git could not answer" and "git says nothing changed" are different facts, and only one of them is a
2734
+ // number. Never render the missing answer as zero.
2531
2735
  if (tracked) {
2532
- console.log('Components edited in this working tree:', changed.length);
2736
+ console.log('Workset detected from git:', changed.length, 'component(s)');
2533
2737
  changed.slice(0, 20).forEach((entry) => {
2534
2738
  const label = (entry.type || 'component') + ' ' + (entry.id || entry.name || '(unknown)');
2535
2739
  const name = entry.name && entry.id ? ' ' + entry.name : '';
@@ -2542,23 +2746,66 @@ function printStageSummary(response, flags) {
2542
2746
  if (!changed.length) {
2543
2747
  console.log(' (no uncommitted component edits — already-committed edits are staged but not listed here)');
2544
2748
  }
2749
+ } else {
2750
+ console.log('Workset detected from git: UNKNOWN (this is not a git working tree)');
2751
+ }
2752
+
2753
+ const submitted = stage.submittedCount != null
2754
+ ? stage.submittedCount
2755
+ : ((response.updated || 0) + (response.unchanged || 0));
2756
+ console.log('Submitted to the platform:', submitted, 'component(s) (' +
2757
+ (response.updated || 0) + ' newly written, ' + (response.unchanged || 0) + ' already current)');
2758
+ if (Array.isArray(response.skipped) && response.skipped.length) {
2759
+ console.log('Skipped (no type/hash/identity):', response.skipped.length);
2545
2760
  }
2546
- console.log('Staging overlay — newly written:', response.updated || 0);
2547
- console.log('Staging overlay — already current:', response.unchanged || 0);
2548
- console.log('Skipped:', Array.isArray(response.skipped) ? response.skipped.length : 0);
2549
- if (response.reconcile) {
2550
- console.log('Reconciled stale keys:', response.reconcile.removedCount || 0);
2761
+ if (stage.removedRetainedCount) {
2762
+ console.log('Removed from the lane by this stage:', stage.removedRetainedCount, 'staged entry(ies)');
2763
+ } else if (response.reconcile) {
2764
+ console.log('Removed from the lane by this stage:', response.reconcile.removedCount || 0, 'staged entry(ies)');
2551
2765
  }
2552
- if (response.staging) {
2553
- console.log('Total staged entries for this branch/user:', response.staging.remainingCount || 0);
2766
+
2767
+ const overlay = lane.stagedCount != null
2768
+ ? lane.stagedCount
2769
+ : (response.staging ? response.staging.remainingCount : null);
2770
+ if (overlay != null) {
2771
+ console.log('Materialized overlay now held by the lane:', overlay, 'component(s)');
2554
2772
  }
2773
+
2774
+ // The trap `--changed-only` leaves behind: it merges, so a lane inherited from an earlier full snapshot
2775
+ // keeps every one of those entries resolving ahead of committed source. Silence here is what made
2776
+ // "I staged 7 components" and "this lane holds 115" look like the same statement.
2777
+ const retained = lane.retainedCount;
2778
+ if (retained > 0) {
2779
+ console.log('');
2780
+ console.log('WARNING: ' + retained + ' staged component(s) in this lane were NOT submitted by this stage.');
2781
+ console.log(' They came from an earlier stage and still shadow committed source for every run in this lane.');
2782
+ console.log(' Make the lane exactly your workset: remits-cli components stage --workset');
2783
+ console.log(' Or drop the overlay entirely: remits-cli components clear --all');
2784
+ }
2785
+
2786
+ printUnrepresentableChanges(response.unrepresentableChanges || (stage.unrepresentable || []));
2555
2787
  printComponentCommandResponse('Components stage', response, flags);
2556
2788
  }
2557
2789
 
2790
+ function describeStageMode(mode) {
2791
+ switch (mode) {
2792
+ case 'full-snapshot':
2793
+ return 'full snapshot (whole repo manifest; the lane is reconciled to it)';
2794
+ case 'workset-replace':
2795
+ return 'workset replace (only the components git reports changed; the lane is reconciled to exactly them)';
2796
+ case 'workset-merge':
2797
+ return 'workset merge (only the components git reports changed, MERGED into whatever the lane already held)';
2798
+ case 'partial':
2799
+ return 'partial (single-component field edit)';
2800
+ default:
2801
+ return mode || 'unknown';
2802
+ }
2803
+ }
2804
+
2558
2805
  function printStatusSummary(response, flags) {
2559
2806
  printBranchContext(response);
2560
2807
  printStagingLane(response.branchName, response.workspace, null);
2561
- console.log('Staged count:', response.stagedCount || 0);
2808
+ printLaneSummary(response);
2562
2809
  printComponentTypeCounts(response.entries || []);
2563
2810
 
2564
2811
  // Every lane staged on this branch, not just this one. An agent that believes it is working alone can
@@ -2577,9 +2824,93 @@ function printStatusSummary(response, flags) {
2577
2824
  console.log(' (* = this command\'s lane)');
2578
2825
  }
2579
2826
 
2827
+ printAccountLanes(response);
2828
+
2580
2829
  printComponentCommandResponse('Components staging', response, flags);
2581
2830
  }
2582
2831
 
2832
+ /**
2833
+ * What THIS lane holds, separated into the work in flight and the overlay carried along with it.
2834
+ *
2835
+ * Read authoritatively from the staging keys (not the lane index), so the numbers here are the ones a run
2836
+ * in this lane will actually resolve.
2837
+ */
2838
+ function printLaneSummary(response) {
2839
+ const lane = response.laneSummary || {};
2840
+ const overlay = lane.stagedCount != null ? lane.stagedCount : (response.stagedCount || 0);
2841
+
2842
+ console.log('Materialized overlay (what a run in this lane resolves):', overlay, 'component(s)');
2843
+ // An EMPTY lane has no last stage, so "Last stage: unknown" would invite a reader to go looking for a
2844
+ // stage that never happened. Nothing staged is clean state, not a missing answer.
2845
+ if (!overlay) {
2846
+ console.log('Nothing is staged in this lane; runs here resolve committed source.');
2847
+ } else if (lane.stageMode && lane.stageMode !== 'unknown') {
2848
+ console.log('Last stage:', describeStageMode(lane.stageMode));
2849
+ if (lane.submittedCount != null) {
2850
+ console.log(' submitted by that stage:', lane.submittedCount, 'component(s)');
2851
+ }
2852
+ // null is "not established", never zero. A lane staged by a build that predates stage provenance has
2853
+ // an unknown workset, and printing 0 would assert that nobody is working in it.
2854
+ if (lane.worksetKnown && lane.worksetCount != null) {
2855
+ console.log(' of which git reported as edited:', lane.worksetCount, 'component(s)');
2856
+ } else {
2857
+ console.log(' of which git reported as edited: unknown (that stage reported no git evidence — an');
2858
+ console.log(' older CLI, a non-git checkout, or a single-field MCP edit)');
2859
+ }
2860
+ if (lane.retainedCount > 0) {
2861
+ console.log(' retained from EARLIER stages:', lane.retainedCount, 'component(s) — these still shadow');
2862
+ console.log(' committed source. `components stage --workset` makes the lane exactly your workset.');
2863
+ }
2864
+ } else {
2865
+ console.log('Last stage: unknown (staged before stage modes existed, or by another door)');
2866
+ }
2867
+
2868
+ if (response.sharedLane === true) {
2869
+ console.log('Lane isolation: SHARED — no workspace. A full stage here REPLACES the lane rather than');
2870
+ console.log(' merging, so a second agent overwrites what you are testing. `remits-cli workspace use --auto`.');
2871
+ }
2872
+ }
2873
+
2874
+ // Every staging lane on the ACCOUNT, across branches.
2875
+ //
2876
+ // The block above only ever covered lanes on the SAME git branch, because that is all its key pattern
2877
+ // could reach. So an agent on trunk could not learn that a sibling had components staged on a variant
2878
+ // branch of the same account — and those lanes are not merely differently named, they resolve and commit
2879
+ // into different worlds. "Am I working alone" was only answerable within one branch, which is the
2880
+ // narrower half of the question.
2881
+ //
2882
+ // Marked "as of" deliberately: this comes from the lane index, not from the staging keys. The caller's own
2883
+ // lane is still reported authoritatively above.
2884
+ function printAccountLanes(response) {
2885
+ const lanes = Array.isArray(response.accountLanes) ? response.accountLanes : [];
2886
+ const others = lanes.filter((lane) => !lane.mine);
2887
+ if (!others.length) return;
2888
+
2889
+ console.log('');
2890
+ console.log('Other staging lanes on this ACCOUNT (as of the last stage/clear in each):');
2891
+ lanes.forEach((lane) => {
2892
+ // null onTrunk means the owner could not be resolved. Say "unknown" rather than guessing a world.
2893
+ const world = lane.onTrunk === true ? 'trunk'
2894
+ : lane.onTrunk === false ? 'variant branch'
2895
+ : 'branch world unknown';
2896
+ const ws = lane.workspace ? ' [ws:' + lane.workspace + ']' : '';
2897
+ const who = lane.userEmail ? ' ' + lane.userEmail : '';
2898
+ const ttl = lane.expiresInSeconds != null
2899
+ ? ' expires in ~' + Math.round(lane.expiresInSeconds / 60) + 'm' : '';
2900
+ // Overlay first (it is what a run resolves), then the work the last stage actually submitted. A lane
2901
+ // showing "115 overlay / 7 workset" is one agent iterating on 7 components, not 115 edits in flight.
2902
+ const workset = (lane.worksetKnown && lane.worksetCountAsOf != null)
2903
+ ? ', workset ' + lane.worksetCountAsOf : '';
2904
+ const stageMode = lane.stageMode && lane.stageMode !== 'unknown' ? ' [' + lane.stageMode + ']' : '';
2905
+ const shared = lane.sharedLane === true ? ' SHARED LANE' : '';
2906
+ console.log(' ' + (lane.mine ? '* ' : ' ') + lane.branchName + ws
2907
+ + ' (' + world + ') — overlay ' + (lane.stagedCountAsOf || 0) + workset + stageMode + shared + who + ttl);
2908
+ });
2909
+ console.log(' (* = this command\'s lane)');
2910
+ console.log(' A variant-branch lane layers over that branch\'s ComponentVariant overlays, and a commit');
2911
+ console.log(' from it writes overlays — never the trunk rows a trunk lane commits to.');
2912
+ }
2913
+
2583
2914
  // Make the working tree's meaning explicit. An agent may be standing in a TRUNK checkout or in a
2584
2915
  // VARIANT branch's checkout, and the two differ on BOTH ends of the loop: what a run resolves, and
2585
2916
  // what a commit writes. Inferring that from the branch name alone is exactly the kind of ambiguity
@@ -2623,8 +2954,8 @@ function printClearSummary(response, flags) {
2623
2954
  * `remits-cli workspace [show|use <name>|clear]`
2624
2955
  *
2625
2956
  * The ergonomic entry point for the staging workspace lane. `use --auto` names the lane after the
2626
- * checkout directory, which is what makes `git worktree add ../ms-agent-a` + one command enough to give
2627
- * an agent its own isolated staging lane on the SAME branch.
2957
+ * checkout directory, which is what makes one workspace file per worktree enough to give an agent its own
2958
+ * isolated staging lane on the SAME branch.
2628
2959
  */
2629
2960
  async function workspaceCommand(flags) {
2630
2961
  const cwd = process.cwd();
@@ -2770,8 +3101,13 @@ async function clearComponentsCommand(flags) {
2770
3101
  return response;
2771
3102
  }
2772
3103
 
2773
- async function syncComponentsCommand(flags) {
3104
+ async function syncComponentsCommand(rawFlags) {
2774
3105
  const cwd = process.cwd();
3106
+ // `--safe` is a NAME for the gate combination that should be the default agent path, not a new gate.
3107
+ // Spelling out --summary --changed-only --fail-on-errors --fail-on-removed correctly every time is the
3108
+ // step agents skip, and skipping it is how a stale branch writes 34 unrelated ComponentVariant rows.
3109
+ const safe = flagEnabled(rawFlags.safe);
3110
+ const flags = safe ? expandSafeSyncFlags(rawFlags) : rawFlags;
2775
3111
  ensureLocalState(cwd);
2776
3112
  const sessionContext = resolveSessionContext(cwd, flags);
2777
3113
  const { session, accountId } = sessionContext;
@@ -2808,10 +3144,20 @@ async function syncComponentsCommand(flags) {
2808
3144
  forceTombstones,
2809
3145
  dryRun,
2810
3146
  mode: 'sync',
3147
+ // Identity and location, so the platform can take the landing lease for THIS session rather than for
3148
+ // an anonymous caller — and so a re-acquire by the same session (the CLI already took it in
3149
+ // `components commit` phase 1) succeeds instead of refusing itself.
3150
+ ...runContextPayload(flags),
3151
+ agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined,
3152
+ acknowledge: flags.acknowledge || undefined,
2811
3153
  components: []
2812
3154
  };
2813
3155
 
2814
3156
  let preflightGate = null;
3157
+ // The flags the gates are actually evaluated with. `--safe` may resolve `--changed-since` for the
3158
+ // caller, and it must be the SAME value the preflight and the post-sync evaluation use, or the two
3159
+ // could disagree about the same plan.
3160
+ let gateFlags = flags;
2815
3161
  if (preflightRequested && !dryRun) {
2816
3162
  const statusResponse = await loggedPost(api, cwd, '/cli/components', {
2817
3163
  token: session.token,
@@ -2824,41 +3170,75 @@ async function syncComponentsCommand(flags) {
2824
3170
 
2825
3171
  const branchContext = statusResponse && statusResponse.branchContext;
2826
3172
  if (!branchContext || branchContext.onTrunk) {
2827
- 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.');
2828
- }
3173
+ // Trunk has no dry-run plan to gate on, and pretending otherwise would be worse than refusing:
3174
+ // an agent would read "safe" and get an ungated authoritative reconcile. Say what trunk sync is
3175
+ // and make the caller assert it.
3176
+ if (!safe) {
3177
+ 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.');
3178
+ }
3179
+ console.log('');
3180
+ console.log('TRUNK SYNC — there is no dry-run plan for trunk, so --safe cannot gate this.');
3181
+ console.log(' This is an AUTHORITATIVE full repo-to-DB reconcile of ' + (branchContext ? branchContext.trunkBranch || branchName : branchName) + ':');
3182
+ console.log(' every component row is rewritten from the pushed repo, and any live component');
3183
+ console.log(' missing from the repo is DELETED.');
3184
+ console.log(' For a gated plan, work on a variant branch instead.');
3185
+ if (!flagEnabled(flags.yes)) {
3186
+ throw new Error('components sync --safe on trunk requires --yes, because the plan cannot be previewed. ' +
3187
+ 'Re-run with --yes to accept the full trunk reconcile, or switch to a variant branch for a gated sync.');
3188
+ }
3189
+ // Drop the gates that can only be evaluated against a plan. Leaving them on would fail the command
3190
+ // AFTER an authoritative reconcile had already been written, which is the worst of both.
3191
+ gateFlags = Object.assign({}, flags, {
3192
+ 'changed-only': false, changedOnly: false,
3193
+ 'fail-on-removed': false, failOnRemoved: false
3194
+ });
3195
+ } else {
3196
+ gateFlags = resolveChangedSinceFlags(flags, cwd, branchContext, safe);
2829
3197
 
2830
- const previewResponse = await loggedPost(api, cwd, '/cli/components', Object.assign({}, syncPayload, {
2831
- dryRun: true
2832
- })).then((r) => r.data);
3198
+ const previewResponse = await loggedPost(api, cwd, '/cli/components', Object.assign({}, syncPayload, {
3199
+ dryRun: true
3200
+ })).then((r) => r.data);
2833
3201
 
2834
- if (!previewResponse.success) {
2835
- throw new Error(previewResponse.message || 'Server sync dry-run failed');
2836
- }
3202
+ if (!previewResponse.success) {
3203
+ throw new Error(previewResponse.message || 'Server sync dry-run failed');
3204
+ }
2837
3205
 
2838
- preflightGate = evaluateSyncGates(previewResponse, flags, changedFromWorkingTree, cwd);
3206
+ preflightGate = evaluateSyncGates(previewResponse, gateFlags, changedFromWorkingTree, cwd);
3207
+
3208
+ if (namesOnly) {
3209
+ printSessionResolutionWarning(sessionContext);
3210
+ printResolvedBaseUrl(baseUrl);
3211
+ console.log('Data mode:', previewResponse.dataMode || dataMode);
3212
+ console.log('Mode:', 'sync dry-run');
3213
+ console.log('Dry run: no variants were written and staging was not cleared.');
3214
+ printSyncNames(previewResponse);
3215
+ failOnSyncGate(preflightGate);
3216
+ return previewResponse;
3217
+ }
2839
3218
 
2840
- if (namesOnly) {
2841
- printSessionResolutionWarning(sessionContext);
2842
- printResolvedBaseUrl(baseUrl);
2843
- console.log('Data mode:', previewResponse.dataMode || dataMode);
2844
- console.log('Mode:', 'sync dry-run');
2845
- console.log('Dry run: no variants were written and staging was not cleared.');
2846
- printSyncNames(previewResponse);
2847
3219
  failOnSyncGate(preflightGate);
2848
- return previewResponse;
2849
- }
2850
3220
 
2851
- failOnSyncGate(preflightGate);
3221
+ // The plan passed every gate. Show it before mutating anyway: a gate proves the plan matches the
3222
+ // changed set, not that the changed set is what the author meant.
3223
+ if (safe && !flagEnabled(flags.yes)) {
3224
+ console.log('');
3225
+ console.log('Planned durable writes (gates passed):');
3226
+ printSyncNames(previewResponse);
3227
+ console.log('');
3228
+ }
3229
+ }
2852
3230
  }
2853
3231
 
2854
- const response = await loggedPost(api, cwd, '/cli/components', syncPayload).then((r) => r.data);
3232
+ // Through the same refusal-aware door as `stage`: the landing lease and the account's commit rules both
3233
+ // answer 409 with the sentence to act on, and that sentence must reach the caller intact.
3234
+ const response = await stageOrRefuse(api, cwd, syncPayload);
2855
3235
 
2856
3236
  if (!response.success) {
2857
3237
  throw new Error(response.message || 'Server sync failed');
2858
3238
  }
2859
3239
 
2860
3240
  const summary = buildSyncSummary(response);
2861
- const gate = preflightGate || evaluateSyncGates(response, flags, changedFromWorkingTree, cwd);
3241
+ const gate = preflightGate || evaluateSyncGates(response, gateFlags, changedFromWorkingTree, cwd);
2862
3242
  summary.gates = gate.checks;
2863
3243
 
2864
3244
  if (flagEnabled(flags.json)) {
@@ -2871,6 +3251,7 @@ async function syncComponentsCommand(flags) {
2871
3251
  printResolvedBaseUrl(baseUrl);
2872
3252
  console.log('Data mode:', response.dataMode || dataMode);
2873
3253
  console.log('Mode:', response.mode || 'sync');
3254
+ printComponentPolicy(response);
2874
3255
  if (response.sync && response.sync.dryRun) {
2875
3256
  console.log('Dry run: no variants were written and staging was not cleared.');
2876
3257
  }
@@ -2967,8 +3348,73 @@ function printPromotionSignals(response) {
2967
3348
  });
2968
3349
  }
2969
3350
 
3351
+ /**
3352
+ * `--safe`: the gate combination that should be the normal agent path for a variant-branch sync.
3353
+ *
3354
+ * Named rather than remembered. The individual flags have existed for a while and the failure they exist
3355
+ * to stop — a stale branch whose old copies of untouched files become unrelated ComponentVariant
3356
+ * overrides — happens precisely when somebody forgets one of them.
3357
+ *
3358
+ * `--expected-removed` already implies fail-on-removed and NAMES the removals the caller accepts, so
3359
+ * --safe must not layer a blanket --fail-on-removed on top of it and contradict the narrower answer.
3360
+ */
3361
+ function expandSafeSyncFlags(flags) {
3362
+ const expectedRemoved = flags['expected-removed'] != null ? flags['expected-removed'] : flags.expectedRemoved;
3363
+ return Object.assign({}, flags, {
3364
+ summary: true,
3365
+ 'fail-on-errors': true,
3366
+ 'changed-only': true,
3367
+ 'fail-on-removed': expectedRemoved != null ? flags['fail-on-removed'] : true
3368
+ });
3369
+ }
3370
+
3371
+ /**
3372
+ * The base ref the `--changed-only` gate compares the server plan against.
3373
+ *
3374
+ * `--changed-only` alone reads UNCOMMITTED edits, and the documented flow commits and pushes BEFORE the
3375
+ * sync (the server syncs by pulling the remote, so it cannot see uncommitted work at all). So at the
3376
+ * moment the gate runs, the working tree is usually clean and the changed set is empty — which used to
3377
+ * make the gate refuse every plan.
3378
+ *
3379
+ * Under --safe the ref is derived from the branch's own merge base with trunk when the caller did not
3380
+ * name one. Local refs only: no implicit `git fetch`, because a status/gate command that quietly reaches
3381
+ * the network is a command whose cost and failure modes the caller did not agree to. When nothing local
3382
+ * can answer, this refuses with the command to run rather than gating against an empty set.
3383
+ */
3384
+ function resolveChangedSinceFlags(flags, cwd, branchContext, safe) {
3385
+ const explicit = flags['changed-since'] || flags.changedSince;
3386
+ if (explicit || !safe) {
3387
+ return flags;
3388
+ }
3389
+ const trunk = (branchContext && branchContext.trunkBranch) || 'main';
3390
+ const candidates = ['origin/' + trunk, trunk];
3391
+ for (const candidate of candidates) {
3392
+ let base = null;
3393
+ try {
3394
+ base = execSync('git merge-base HEAD ' + JSON.stringify(candidate), {
3395
+ cwd,
3396
+ stdio: ['ignore', 'pipe', 'pipe']
3397
+ }).toString().trim();
3398
+ } catch (_) {
3399
+ continue;
3400
+ }
3401
+ if (base) {
3402
+ console.log('--safe: comparing the sync plan against everything this branch added since ' +
3403
+ candidate + ' (' + base.slice(0, 12) + ').');
3404
+ return Object.assign({}, flags, { 'changed-since': base });
3405
+ }
3406
+ }
3407
+ throw new Error(
3408
+ 'components sync --safe needs a base ref to compare the plan against, and neither origin/' + trunk +
3409
+ ' nor ' + trunk + ' could be resolved locally.\n' +
3410
+ ' Run `git fetch origin ' + trunk + '` and retry, or pass it yourself:\n' +
3411
+ ' remits-cli components sync --safe --changed-since origin/' + trunk
3412
+ );
3413
+ }
3414
+
2970
3415
  function syncPreflightRequested(flags) {
2971
3416
  return Boolean(
3417
+ flagEnabled(flags.safe) ||
2972
3418
  flagEnabled(flags['names-only']) ||
2973
3419
  flagEnabled(flags.namesOnly) ||
2974
3420
  flagEnabled(flags['changed-only']) ||
@@ -3088,7 +3534,16 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
3088
3534
  ? '\n The changed set is EMPTY: --changed-only reads UNCOMMITTED edits, and the documented flow ' +
3089
3535
  'commits and pushes before syncing. Pass --changed-since <ref> (e.g. the commit you branched ' +
3090
3536
  'from, or origin/main) so the gate can see committed work.'
3091
- : '';
3537
+ // A plan that reaches well beyond a real changed set is almost always a STALE BRANCH: it still
3538
+ // physically carries old copies of files nobody on it touched, and a variant sync turns each of
3539
+ // those into an unrelated override. Naming the cause is the difference between a gate that
3540
+ // stops the damage and a gate that also tells you how to clear it.
3541
+ : (changedSet.length && unexpected.length > changedSet.length)
3542
+ ? '\n Most likely cause: this branch is BEHIND trunk and still carries old copies of files ' +
3543
+ 'it never changed. A variant sync turns each of those into an unrelated override.' +
3544
+ '\n Next step: merge trunk into this branch, push, then re-run the same command:' +
3545
+ '\n git merge <trunk> && git push origin <branch>'
3546
+ : '';
3092
3547
  violations.push('--changed-only: plan touches ' + unexpected.length + ' component(s) this checkout did not change: ' +
3093
3548
  unexpected.slice(0, 20).map(syncEntryToken).join(', ') + (unexpected.length > 20 ? ', ...' : '') + hint);
3094
3549
  }
@@ -3551,6 +4006,17 @@ async function commitComponentsCommand(flags) {
3551
4006
  throw new Error('components commit does not support --dry-run because it performs local git writes. Use `remits-cli components sync --dry-run`.');
3552
4007
  }
3553
4008
 
4009
+ // Landing is serial and nothing can make it concurrent: `git add -A` sweeps a shared checkout, and the
4010
+ // platform pushes a regenerated `account-info.json` back to the branch during sync, so two commits
4011
+ // racing one branch collide on the remote.
4012
+ //
4013
+ // Taken HERE, before `git push`, and not after. Once the push has happened the platform can only refuse
4014
+ // the SYNC, and a pushed-but-unsynced branch is a state nobody is watching.
4015
+ //
4016
+ // The honest limit: this cannot stop a raw `git push` somebody runs themselves. It makes `remits-cli
4017
+ // components commit` serial, which is the path everything actually uses.
4018
+ const landing = await acquireLandingLease(flags, accountId, branchName);
4019
+ try {
3554
4020
  if (!skipGit) {
3555
4021
  console.log('Phase 1/3: local git commit/push');
3556
4022
  const status = runGit(cwd, 'git status --porcelain');
@@ -3580,6 +4046,14 @@ async function commitComponentsCommand(flags) {
3580
4046
  }
3581
4047
 
3582
4048
  console.log('Phase 2/3: server sync');
4049
+ if (flagEnabled(flags.safe)) {
4050
+ // The honest limit, stated where it applies. `components commit` runs git add/commit/push BEFORE the
4051
+ // server sync, so a --safe gate here cannot stop the push — it stops the PLATFORM from writing bad
4052
+ // overlays. The branch may already be pushed when the gate refuses; that is recoverable, an
4053
+ // unintended ComponentVariant tree is much less so.
4054
+ console.log(' --safe: the branch is already pushed. The gate below stops the platform from WRITING a');
4055
+ console.log(' surprising plan; it cannot un-push. Use `components sync --safe` alone for a pre-push gate.');
4056
+ }
3583
4057
  const syncResponse = await syncComponentsCommand({
3584
4058
  ...flags,
3585
4059
  branch: branchName,
@@ -3610,6 +4084,71 @@ async function commitComponentsCommand(flags) {
3610
4084
  console.log('Verified local HEAD matches remits sync SHA:', expectedSha);
3611
4085
  }
3612
4086
  }
4087
+ } finally {
4088
+ // Give the branch back so the next agent lands in seconds rather than waiting out the TTL. Nothing
4089
+ // depends on this happening — the lease expires on its own, which is what covers the run that is
4090
+ // killed before it gets here.
4091
+ await releaseLandingLease(landing);
4092
+ }
4093
+ }
4094
+
4095
+ /**
4096
+ * Take the exclusive right to land this branch, or stop with the sentence saying who has it.
4097
+ *
4098
+ * Soft-fails on an OLDER PLATFORM that does not know the `land` mode (400/404): the CLI must keep working
4099
+ * against a platform it is ahead of, and the server takes the lease itself on the sync anyway — so the
4100
+ * worst case is that the refusal arrives one phase later than it should, not that it stops arriving.
4101
+ */
4102
+ async function acquireLandingLease(flags, accountId, branchName) {
4103
+ const cwd = process.cwd();
4104
+ const { session } = resolveSessionContext(cwd, flags);
4105
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
4106
+ const payload = {
4107
+ token: session.token,
4108
+ accountId,
4109
+ branchName,
4110
+ mode: 'land',
4111
+ dataMode: resolveDataMode(flags, session),
4112
+ ...runContextPayload(flags),
4113
+ agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined
4114
+ };
4115
+ try {
4116
+ const response = await buildAxios(baseUrl, session.token, 20000)
4117
+ .post('/cli/components', payload).then((r) => r.data);
4118
+ if (response && response.success) {
4119
+ console.log('Landing lease: held on ' + branchName + ' (' +
4120
+ (response.ttlMinutes || '?') + 'm, renewed by the sync).');
4121
+ return { flags, accountId, branchName, baseUrl, session, held: true };
4122
+ }
4123
+ throw new Error((response && response.message) || 'Could not take the landing lease.');
4124
+ } catch (err) {
4125
+ const body = err && err.response && err.response.data;
4126
+ const status = err && err.response && err.response.status;
4127
+ if (body && body.message && status === 409) {
4128
+ throw new Error(body.message);
4129
+ }
4130
+ if (status === 400 || status === 404) {
4131
+ return { held: false };
4132
+ }
4133
+ if (body && body.message) {
4134
+ throw new Error(body.message);
4135
+ }
4136
+ throw err;
4137
+ }
4138
+ }
4139
+
4140
+ async function releaseLandingLease(landing) {
4141
+ if (!landing || !landing.held) return;
4142
+ try {
4143
+ await buildAxios(landing.baseUrl, landing.session.token, 20000).post('/cli/components', {
4144
+ token: landing.session.token,
4145
+ accountId: landing.accountId,
4146
+ branchName: landing.branchName,
4147
+ mode: 'unland',
4148
+ ...runContextPayload(landing.flags),
4149
+ agentId: landing.flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined
4150
+ });
4151
+ } catch (_) { /* best-effort: the lease expires on its own */ }
3613
4152
  }
3614
4153
 
3615
4154
  function webSocketUrl(baseUrl) {
@@ -3717,6 +4256,7 @@ async function testCommand(flags) {
3717
4256
  const dataMode = hasExplicitDataModeFlag(flags)
3718
4257
  ? resolveDataMode(flags, null)
3719
4258
  : DEFAULT_DATA_MODE;
4259
+ const dataModeSource = dataModeFlagSource(flags);
3720
4260
  const testRef = flags.test || flags['test-id'] || flags.name;
3721
4261
 
3722
4262
  if (!testRef) {
@@ -3768,6 +4308,7 @@ async function testCommand(flags) {
3768
4308
  branchName,
3769
4309
  workspace,
3770
4310
  dataMode,
4311
+ dataModeSource,
3771
4312
  testId: /^\d+$/.test(String(testRef)) ? Number(testRef) : undefined,
3772
4313
  testName: /^\d+$/.test(String(testRef)) ? undefined : String(testRef),
3773
4314
  tests: names
@@ -5283,6 +5824,7 @@ async function collectAgentSnapshot() {
5283
5824
  label: entry.label || entry.agentId,
5284
5825
  alive: !!(worker.pid && processIsAlive(worker.pid))
5285
5826
  }))), []),
5827
+ recentWorkers: recentWorkerRuns(24),
5286
5828
  errors
5287
5829
  };
5288
5830
  agentSnapshotCache = { fetchedAtMs: nowMs, value };
@@ -5572,12 +6114,18 @@ function renderDashboardHtml(snapshot) {
5572
6114
  'function supportTicketDetails(state,ticket,key){const agents=((state.agents||{}).registered)||[];const ticketAccountId=String(ticket.accountId||"");const eligible=agents.filter(function(a){if(ticket.baseUrl&&a.baseUrl&&a.baseUrl!==ticket.baseUrl)return false;if(ticket.dataMode&&a.dataMode&&a.dataMode!==ticket.dataMode)return false;return (a.accounts||[]).some(function(acc){return String(acc.accountId)===ticketAccountId||acc.routingScope==="self_and_descendants";});});const options=[\'<option value="">- unrouted -</option>\'].concat(eligible.map(function(a){const sel=a.agentId===ticket.routedAgentId?" selected":"";return \'<option value="\'+escapeHtml(a.agentId)+\'"\'+sel+\'>\'+escapeHtml((a.label||a.userName||a.agentId)+" - "+(a.state||"idle")+(a.activity?" ("+a.activity+")":""))+\'</option>\';})).join("");const routeControl=eligible.length?(\'<div class="mini-card"><strong>Route To Agent</strong><div style="display:flex;gap:8px;margin-top:6px;"><select data-route-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'" data-route-account="\'+escapeHtml(ticketAccountId)+\'" data-route-base="\'+escapeHtml(ticket.baseUrl||"")+\'" data-route-mode="\'+escapeHtml(ticket.dataMode||"")+\'" style="flex:1;min-width:0;">\'+options+\'</select><button class="action-btn" data-action="route-ticket" data-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'">Route</button></div></div>\'):\'<div class="mini-card"><strong>Route To Agent</strong>No registered agent covers this account.</div>\';const lane=ticket.dataMode||"prod";const planning=[ticket.workstream?("Workstream "+ticket.workstream):"",ticket.plannedIn?("Planned "+ticket.plannedIn):"",ticket.boardStage?("Board "+ticket.boardStage):"",ticket.rank!==undefined&&ticket.rank!==null&&ticket.rank!==""?("Rank "+ticket.rank):"",ticket.size?("Size "+ticket.size):"",ticket.blockedBy?("Blocked by "+ticket.blockedBy):""].filter(Boolean).join(" / ");const command="remits-cli ticket read --ticket "+String(ticket.ticketId||"")+" --data-mode "+lane+"\\nremits-cli ticket accept --ticket "+String(ticket.ticketId||"")+" --data-mode "+lane;return \'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(ticket.ticketId||"n/a")+\'</div><div class="mini-card"><strong>Account</strong>\'+escapeHtml((ticket.accountName||"n/a")+" ("+(ticket.accountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Type</strong>\'+escapeHtml(ticket.type||"n/a")+\'</div><div class="mini-card"><strong>Owned By</strong>\'+escapeHtml(ticket.assignedTo||"unassigned")+\'</div><div class="mini-card"><strong>Routed To</strong>\'+escapeHtml(ticket.routedAgentLabel||ticket.routedAgentId||"nobody")+\'</div><div class="mini-card"><strong>Implementation</strong>\'+escapeHtml((ticket.implementationAccountName||"n/a")+" ("+(ticket.implementationAccountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Planning</strong>\'+escapeHtml(planning||"No planning fields")+\'</div></div>\'+routeControl+\'<div class="muted">\'+escapeHtml(ticket.affectedComponent||"No affected component recorded.")+\'</div>\'+codeBlock(key+":cmd",command)+\'</div>\';}' +
5573
6115
  'function renderWebsockets(state){const entries=Object.entries((state.websocket&&state.websocket.connections)||{});const target=document.getElementById("websocket-section");if(!entries.length){target.innerHTML=\'<div class="empty">No websocket connections are registered yet.</div>\';return;}target.innerHTML=entries.map(function(pair){const baseUrl=pair[0];const info=pair[1]||{};const topicEntries=Object.entries(info.topics||{});const recentMessages=Array.isArray(info.recentMessages)?info.recentMessages:[];const tone=info.status==="connected"?"ok":(info.status==="connecting"?"warn":"bad");const summaryMeta=badge(info.status||"unknown",tone)+\' <span class="muted">\' + escapeHtml(String(topicEntries.length)) + \' topic(s)</span>\' + \' <span class="muted">\' + escapeHtml(String(recentMessages.length)) + \' recent message(s)</span>\';const topicBody=topicEntries.length?topicEntries.map(function(topicPair){const topic=topicPair[0];const topicInfo=topicPair[1]||{};const mini=\'<div class="mini-grid"><div class="mini-card"><strong>Accounts</strong>\'+escapeHtml((topicInfo.accountIds||[]).join(", ")||"n/a")+\'</div><div class="mini-card"><strong>Subscribed</strong>\'+escapeHtml(formatTime(topicInfo.subscribedAt))+\'</div><div class="mini-card"><strong>Last Message</strong>\'+escapeHtml(formatTime(topicInfo.lastMessageAt))+\'</div></div>\';return detailsBlock("ws-topic:"+baseUrl+":"+topic,topic,"topic subscription",mini,false);}).join(""):\'<div class="empty">No topic subscriptions recorded.</div>\';const messageBody=recentMessages.length?recentMessages.map(function(message,index){const messageMeta=badge(message.duplicate?"duplicate":"handled",message.duplicate?"warn":"ok")+\' <span class="muted">\'+escapeHtml(message.source||"live")+\'</span>\' + \' <span class="muted">\'+escapeHtml(message.type||"unknown-type")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Message ID</strong>\'+escapeHtml(message.messageId||"n/a")+\'</div><div class="mini-card"><strong>Timestamp</strong>\'+escapeHtml(message.timestamp?String(message.timestamp):"n/a")+\'</div><div class="mini-card"><strong>Received</strong>\'+escapeHtml(formatTime(message.receivedAt))+\'</div><div class="mini-card"><strong>Accounts</strong>\'+escapeHtml((message.accountIds||[]).join(", ")||"n/a")+\'</div></div>\'+codeBlock("ws-msg:"+baseUrl+":"+(message.messageId||index),JSON.stringify(message.payload||{},null,2)) + \'</div>\';return detailsBlock("ws-message:"+baseUrl+":"+(message.messageId||index),(message.type||"message")+" "+(message.messageId||""),messageMeta,body,false);}).join(""):\'<div class="empty">No recent websocket messages have been recorded yet.</div>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Status</strong>\'+escapeHtml(info.status||"unknown")+\'</div><div class="mini-card"><strong>Connected</strong>\'+escapeHtml(formatTime(info.connectedAt))+\'</div><div class="mini-card"><strong>Last Message</strong>\'+escapeHtml(formatTime(info.lastMessageAt))+\'</div><div class="mini-card"><strong>Last Error</strong>\'+escapeHtml(info.lastError||"none")+\'</div></div><div class="stack">\'+detailsBlock("ws-recent:"+baseUrl,"Recent messages","latest live and missed-message traffic",messageBody,false)+topicBody+\'</div></div>\';return detailsBlock("ws:"+baseUrl,baseUrl,summaryMeta,body,entries.length===1);}).join("");}' +
5574
6116
  'function agentTone(agent){if(!agent||agent.online===false)return "bad";const s=agent.state||"idle";return s==="paused"?"warn":"ok";}' +
5575
- 'function renderWorkers(state){const target=document.getElementById("worker-section");const workers=((state.agents||{}).workers)||[];if(!workers.length){target.innerHTML=\'<div class="empty">No autonomous ticket workers are running. Start one with <strong>remits-cli agent serve</strong> — it launches a fresh headless agent process per routed ticket, so no session accumulates context across tickets.</div>\';return;}target.innerHTML=workers.map(function(w,index){const meta=badge(w.agentType||"agent",w.alive?"ok":"bad")+\' \'+badge(w.alive?"running":"exited",w.alive?"ok":"bad")+\' <span class="muted">attempt \'+escapeHtml(String(w.attempt||1))+\'</span>\';const t=escapeHtml(String(w.ticketId||""));const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(String(w.ticketId||"?"))+\'</div><div class="mini-card"><strong>Doing Now</strong>\'+escapeHtml(w.activity||"nothing reported yet")+\'</div><div class="mini-card"><strong>Started</strong>\'+escapeHtml(formatTime(w.startedAt))+\'</div><div class="mini-card"><strong>Pid</strong>\'+escapeHtml(String(w.pid||"?"))+\'</div><div class="mini-card"><strong>Repo</strong>\'+escapeHtml(w.workingDirectory||"(none resolved)")+\'</div><div class="mini-card"><strong>Supervisor</strong>\'+escapeHtml(w.label||w.agentId||"?")+\'</div></div><div class="wlog-bar"><button class="action-btn" data-follow-ticket="\'+t+\'">Follow live</button><label class="muted"><input type="checkbox" data-follow-tail="\'+t+\'" checked> stick to bottom</label><span class="muted" data-follow-status="\'+t+\'"></span></div><div class="wlog" data-follow-log="\'+t+\'"><div class="muted">Press <strong>Follow live</strong> to stream this run.</div></div>\'+codeBlock("wlog-tail:"+t,"tail -f "+(w.logFile||""))+\'</div>\';return detailsBlock("worker:"+w.ticketId,"Ticket "+w.ticketId+" - "+(w.subject||"(no subject)"),meta,body,index===0);}).join("");restartActiveFollows();}' +
6117
+ 'function basename(value){return String(value||"").split(/[\\\\/]/).pop();}' +
6118
+ 'function workerRunKey(run){return run.logName||basename(run.logFile)||String(run.ticketId||"");}' +
6119
+ 'function workerControls(run){const key=escapeHtml(workerRunKey(run));const ticket=escapeHtml(String(run.ticketId||""));const log=escapeHtml(run.logName||basename(run.logFile)||"");return \'<div class="wlog-bar"><button class="action-btn" data-follow-key="\'+key+\'" data-follow-ticket="\'+ticket+\'" data-follow-run="\'+log+\'">Follow</button><button class="action-btn" data-unfollow-key="\'+key+\'" disabled>Stop</button><label class="muted"><input type="checkbox" data-follow-tail="\'+key+\'" checked> stick to bottom</label><span class="muted" data-follow-status="\'+key+\'"></span></div><div class="wlog" data-follow-log="\'+key+\'"><div class="muted">Press <strong>Follow</strong> to stream this transcript.</div></div>\';}' +
6120
+ 'function workerRunCard(run,index,prefix){const key=workerRunKey(run);const running=run.alive||run.running;const meta=badge(run.agentType||"worker",running?"ok":"warn")+" "+badge(running?"running":"finished",running?"ok":"warn")+" <span class=\\"muted\\">"+escapeHtml(formatTime(run.startedAt||run.updatedAt))+"</span>";const result=run.result?detailsBlock(prefix+":result:"+key,"Last message","worker closing summary",codeBlock(prefix+":result-code:"+key,run.result),false):"";const cardRows=[["Ticket",String(run.ticketId||"?")],["Doing Now",run.activity||"nothing reported"],["Started",formatTime(run.startedAt)],["Updated",formatTime(run.updatedAt)],["Pid",String(run.pid||"not running")],["Repo",run.workingDirectory||"(not recorded)"],["Target Branch",run.targetBranch||"n/a"],["Workspace",run.workspace||"n/a"],["Suggested Worktree",run.suggestedWorktree||"n/a"],["Log",run.logFile||""]];const cards=cardRows.map(function(pair){return "<div class=\\"mini-card\\"><strong>"+escapeHtml(pair[0])+"</strong>"+escapeHtml(pair[1])+"</div>";}).join("");const body="<div class=\\"stack\\"><div class=\\"mini-grid\\">"+cards+"</div>"+workerControls(run)+result+codeBlock(prefix+":tail:"+key,"tail -f "+(run.logFile||""))+"</div>";return detailsBlock(prefix+":"+key,"Ticket "+String(run.ticketId||"?")+" - "+(run.subject||basename(run.logFile)||"worker run"),meta,body,index===0);}' +
6121
+ 'function renderWorkers(state){const target=document.getElementById("worker-section");const workers=((state.agents||{}).workers)||[];const recent=((state.agents||{}).recentWorkers)||[];let html=\'<div class="mini-grid"><div class="mini-card"><strong>Running Now</strong>\'+escapeHtml(String(workers.length))+\'</div><div class="mini-card"><strong>Recent Transcripts</strong>\'+escapeHtml(String(recent.length))+\'</div></div>\';html+=workers.length?detailsBlock("workers:live","Running workers","active headless ticket processes",workers.map(function(w,index){return workerRunCard(w,index,"worker-live");}).join(""),true):\'<div class="empty">No autonomous ticket workers are running right now.</div>\';html+=recent.length?detailsBlock("workers:recent","Recent worker runs","finished and running local transcripts",recent.map(function(w,index){return workerRunCard(w,index,"worker-history");}).join(""),true):\'<div class="empty">No worker transcripts have been recorded yet.</div>\';target.innerHTML=html;restartActiveFollows();}' +
5576
6122
  'var followState={};' +
5577
6123
  'function eventHtml(ev){if(!ev)return "";const t=escapeHtml(ev.text||"");if(ev.kind==="command"){const bad=(ev.exitCode!==null&&ev.exitCode!==undefined&&ev.exitCode!==0);const out=ev.detail?\'<span class="out">\'+escapeHtml(String(ev.detail).slice(0,2000))+\'</span>\':"";return \'<div class="ev cmd\'+(bad?" bad":"")+\'">$ \'+t+(bad?\' <span class="muted">(exit \'+escapeHtml(String(ev.exitCode))+\')</span>\':"")+out+\'</div>\';}if(ev.kind==="message")return \'<div class="ev msg">\'+t+\'</div>\';if(ev.kind==="thinking")return \'<div class="ev think">\'+t+\'</div>\';if(ev.kind==="edit")return \'<div class="ev edit">\'+t+\'</div>\';if(ev.kind==="result")return \'<div class="ev res">\'+t+\'</div>\';if(ev.kind==="raw")return \'<div class="ev raw">\'+t+\'</div>\';return \'<div class="ev">\'+t+\'</div>\';}' +
5578
- 'async function pollFollow(ticket){const st=followState[ticket];if(!st)return;const box=document.querySelector(\'[data-follow-log="\'+ticket+\'"]\');const status=document.querySelector(\'[data-follow-status="\'+ticket+\'"]\');if(!box){return;}try{const r=await fetch("/api/worker-log?ticket="+encodeURIComponent(ticket)+"&offset="+st.offset);const d=await r.json();if(d.missing){status&&(status.textContent="no transcript yet");}else{if(st.offset===0)box.innerHTML="";st.offset=d.offset||st.offset;const html=(d.events||[]).map(eventHtml).join("");if(html){box.insertAdjacentHTML("beforeend",html);const stick=document.querySelector(\'[data-follow-tail="\'+ticket+\'"]\');if(!stick||stick.checked)box.scrollTop=box.scrollHeight;}status&&(status.textContent=(d.running?"live":"finished")+" - "+(d.size||0)+" bytes");if(!d.running&&d.offset>=d.size){st.settleCount=(st.settleCount||0)+1;if(st.settleCount>2){clearInterval(st.timer);delete followState[ticket];return;}}else{st.settleCount=0;}}}catch(e){status&&(status.textContent="stream error");}}' +
5579
- 'function startFollow(ticket){if(followState[ticket]){clearInterval(followState[ticket].timer);}followState[ticket]={offset:0,timer:null};pollFollow(ticket);followState[ticket].timer=setInterval(function(){pollFollow(ticket);},2000);}' +
5580
- 'function restartActiveFollows(){Object.keys(followState).forEach(function(t){const box=document.querySelector(\'[data-follow-log="\'+t+\'"]\');if(!box){clearInterval(followState[t].timer);delete followState[t];return;}const st=followState[t];st.offset=0;box.innerHTML="";pollFollow(t);});}' +
6124
+ 'function setFollowButtons(key,following){document.querySelectorAll(\'[data-follow-key="\'+key+\'"]\').forEach(function(b){b.disabled=following;});document.querySelectorAll(\'[data-unfollow-key="\'+key+\'"]\').forEach(function(b){b.disabled=!following;});}' +
6125
+ 'async function pollFollow(key){const st=followState[key];if(!st)return;const box=document.querySelector(\'[data-follow-log="\'+key+\'"]\');const status=document.querySelector(\'[data-follow-status="\'+key+\'"]\');if(!box){stopFollow(key,true);return;}try{let url="/api/worker-log?ticket="+encodeURIComponent(st.ticket)+"&offset="+st.offset;if(st.logName)url+="&log="+encodeURIComponent(st.logName);const r=await fetch(url);const d=await r.json();if(d.missing){status&&(status.textContent="no transcript yet");}else{if(st.offset===0)box.innerHTML="";st.offset=d.offset||st.offset;const html=(d.events||[]).map(eventHtml).join("");if(html){box.insertAdjacentHTML("beforeend",html);const stick=document.querySelector(\'[data-follow-tail="\'+key+\'"]\');if(!stick||stick.checked)box.scrollTop=box.scrollHeight;}status&&(status.textContent=(d.running?"live":"finished")+" - "+(d.size||0)+" bytes");if(!d.running&&d.offset>=d.size){st.settleCount=(st.settleCount||0)+1;if(st.settleCount>2){stopFollow(key,false);return;}}else{st.settleCount=0;}}}catch(e){status&&(status.textContent="stream error");}}' +
6126
+ 'function startFollow(key,ticket,logName){if(followState[key])stopFollow(key,true);followState[key]={ticket:ticket,logName:logName,offset:0,timer:null};setFollowButtons(key,true);const box=document.querySelector(\'[data-follow-log="\'+key+\'"]\');if(box)box.innerHTML="";pollFollow(key);followState[key].timer=setInterval(function(){pollFollow(key);},2000);}' +
6127
+ 'function stopFollow(key,silent){const st=followState[key];if(st&&st.timer)clearInterval(st.timer);delete followState[key];setFollowButtons(key,false);const status=document.querySelector(\'[data-follow-status="\'+key+\'"]\');if(status&&!silent)status.textContent="stopped";}' +
6128
+ 'function restartActiveFollows(){Object.keys(followState).forEach(function(key){const box=document.querySelector(\'[data-follow-log="\'+key+\'"]\');if(!box){stopFollow(key,true);return;}const st=followState[key];st.offset=0;box.innerHTML="";setFollowButtons(key,true);pollFollow(key);});}' +
5581
6129
  'function renderAgents(state){const target=document.getElementById("agent-section");const info=state.agents||{};const agents=info.registered||[];const local=info.local||[];const errors=info.errors||[];let html=\'<div class="mini-grid"><div class="mini-card"><strong>Registered</strong>\'+escapeHtml(String(agents.length))+\'</div><div class="mini-card"><strong>On This Machine</strong>\'+escapeHtml(String(local.length))+\'</div><div class="mini-card"><strong>Fetched</strong>\'+escapeHtml(formatTime(info.fetchedAt))+\'</div></div>\';if(errors.length){html+=detailsBlock("agents:errors","Agent lookup errors",badge(String(errors.length)+" error(s)","warn"),codeBlock("agents-errors",JSON.stringify(errors,null,2),"log"),false);}if(!agents.length){target.innerHTML=html+\'<div class="empty">No local agent sessions are registered. In each terminal tab that should work tickets, run <strong>remits-cli agent register</strong>. Each tab becomes its own routable agent and stops receiving work when the tab closes.</div>\';return;}html+=agents.map(function(agent,index){const tone=agentTone(agent);const meta=badge(agent.state||"idle",tone)+\' \'+badge((agent.dataMode||"prod")+" lane",agent.dataMode==="test"?"warn":"ok")+\' <span class="muted">\'+escapeHtml(agent.baseUrl||"?")+\' · seen \'+escapeHtml(agent.secondsSinceSeen==null?"?":agent.secondsSinceSeen+"s ago")+\'</span>\';const accounts=(agent.accounts||[]).map(function(a){return (a.name||a.accountId)+" ("+a.accountId+(a.routingScope==="self_and_descendants"?", +descendants":"")+")";}).join(", ");const history=(agent.history||[]).map(function(h){return \'<li><span class="muted">\'+escapeHtml(formatTime(h.at))+\'</span> \'+escapeHtml((h.state||"")+(h.ticketId?" ticket "+h.ticketId:"")+(h.activity?" - "+h.activity:""))+\'</li>\';}).join("");const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Doing Now</strong>\'+escapeHtml(agent.activity||"nothing reported")+\'</div><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(agent.ticketId||"none")+\'</div><div class="mini-card"><strong>Step</strong>\'+escapeHtml(agent.step||"n/a")+\'</div><div class="mini-card"><strong>User</strong>\'+escapeHtml(agent.userEmail||agent.userName||"n/a")+\'</div><div class="mini-card"><strong>Where</strong>\'+escapeHtml((agent.host||"?")+":"+(agent.cwd||"?"))+\'</div><div class="mini-card"><strong>Tickets Routed</strong>\'+escapeHtml(String(agent.ticketsAssigned||0))+\'</div></div><div class="mini-card"><strong>Covers</strong>\'+escapeHtml(accounts||"none")+\'</div><div><strong style="display:block;margin:12px 0 8px 0;">Recent activity</strong><ul style="margin:0;padding-left:18px;display:grid;gap:6px;">\'+(history||"<li>Nothing recorded yet.</li>")+\'</ul></div></div>\';return detailsBlock("agent:"+agent.agentId,(agent.label||agent.userName||agent.agentId),meta,body,index===0);}).join("");target.innerHTML=html;}' +
5582
6130
  'function renderActivity(state){const target=document.getElementById("activity-section");const logFile=(state.globalFiles||[]).find(function(file){return file.label==="activity.log (tail)";});target.innerHTML=detailsBlock("activity:log","Activity tail","service lifecycle, websocket, and agent events",codeBlock("activity-log",logFile&&logFile.content||"[missing]","log"),true);}' +
5583
6131
  'function renderGlobalFiles(state){const target=document.getElementById("global-files");const files=(state.globalFiles||[]).filter(function(file){return file.label!=="activity.log (tail)";});if(!files.length){target.innerHTML=\'<div class="empty">No global files found.</div>\';return;}target.innerHTML=files.map(function(file){const tone=file.exists?"ok":"warn";const meta=badge(file.exists?"present":"missing",tone)+\' <span class="muted">\'+escapeHtml(file.updatedAt?formatTime(file.updatedAt):"not found")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Path</strong>\'+escapeHtml(file.path||"")+\'</div><div class="mini-card"><strong>Size</strong>\'+escapeHtml(String(file.size||0))+\' bytes</div><div class="mini-card"><strong>Updated</strong>\'+escapeHtml(formatTime(file.updatedAt))+\'</div></div>\'+codeBlock("global:"+file.label,file.content)+(file.label==="service-state.json"?\'<div class="muted">Use this file to discover the dashboard URL and runtime websocket summary.</div>\':"")+\'</div>\';return detailsBlock("global:"+file.label,file.label,meta,body,false);}).join("");}' +
@@ -5586,7 +6134,7 @@ function renderDashboardHtml(snapshot) {
5586
6134
  'async function fetchState(){const res=await fetch("/api/state",{cache:"no-store"});if(!res.ok)throw new Error("State refresh failed with "+res.status);return res.json();}' +
5587
6135
  'function showToast(message){const toast=document.getElementById("toast");toast.textContent=message;toast.classList.add("show");clearTimeout(showToast._timer);showToast._timer=setTimeout(function(){toast.classList.remove("show");},2400);}' +
5588
6136
  'async function triggerAction(action,button,extra){if(actionInFlight)return;actionInFlight=true;button.disabled=true;try{let body="action="+encodeURIComponent(action);Object.keys(extra||{}).forEach(function(k){body+="&"+encodeURIComponent(k)+"="+encodeURIComponent(extra[k]==null?"":extra[k]);});const res=await fetch("/action",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},body:body});if(!res.ok)throw new Error("Action failed with "+res.status);const payload=await res.json();renderAll(payload.state);if(payload.result&&payload.result.bundlePath){showToast("Issue bundle prepared at "+payload.result.bundlePath);}else{showToast(action.replace(/-/g," ")+" complete");}}catch(err){showToast(err.message||String(err));}finally{actionInFlight=false;button.disabled=false;}}' +
5589
- 'document.addEventListener("click",function(event){const follow=event.target.closest?event.target.closest("[data-follow-ticket]"):null;if(follow){startFollow(follow.getAttribute("data-follow-ticket"));return;}const button=event.target.closest?event.target.closest("[data-action]"):null;if(!button)return;const action=button.getAttribute("data-action");let extra=null;if(action==="route-ticket"){const ticketId=button.getAttribute("data-ticket");const select=document.querySelector(\'[data-route-ticket="\'+ticketId+\'"]\');if(!select){showToast("No agent selector for that ticket.");return;}extra={ticketId:ticketId,accountId:select.getAttribute("data-route-account"),agentId:select.value,baseUrl:select.getAttribute("data-route-base"),dataMode:select.getAttribute("data-route-mode")};}triggerAction(action,button,extra);});' +
6137
+ 'document.addEventListener("click",function(event){const stop=event.target.closest?event.target.closest("[data-unfollow-key]"):null;if(stop){stopFollow(stop.getAttribute("data-unfollow-key"),false);return;}const follow=event.target.closest?event.target.closest("[data-follow-key]"):null;if(follow){startFollow(follow.getAttribute("data-follow-key"),follow.getAttribute("data-follow-ticket"),follow.getAttribute("data-follow-run"));return;}const button=event.target.closest?event.target.closest("[data-action]"):null;if(!button)return;const action=button.getAttribute("data-action");let extra=null;if(action==="route-ticket"){const ticketId=button.getAttribute("data-ticket");const select=document.querySelector(\'[data-route-ticket="\'+ticketId+\'"]\');if(!select){showToast("No agent selector for that ticket.");return;}extra={ticketId:ticketId,accountId:select.getAttribute("data-route-account"),agentId:select.value,baseUrl:select.getAttribute("data-route-base"),dataMode:select.getAttribute("data-route-mode")};}triggerAction(action,button,extra);});' +
5590
6138
  'renderAll(dashboardState);' +
5591
6139
  'setInterval(function(){fetchState().then(renderAll).catch(function(err){showToast(err.message||String(err));});},refreshMs);' +
5592
6140
  '})();' +
@@ -5785,6 +6333,118 @@ function workerTranscriptFor(ticketId, fromOffset) {
5785
6333
  };
5786
6334
  }
5787
6335
 
6336
+ function workerRunFromLogName(name) {
6337
+ const match = /^(\d+)-(\d+)\.log$/.exec(String(name || ''));
6338
+ if (!match) {
6339
+ return null;
6340
+ }
6341
+ return { ticketId: match[1], timestampMs: Number(match[2]) || null };
6342
+ }
6343
+
6344
+ function resultTextForWorkerLog(logFile) {
6345
+ const parsed = workerRunFromLogName(path.basename(logFile || ''));
6346
+ if (!parsed) {
6347
+ return null;
6348
+ }
6349
+ const exact = logFile.replace(/\.log$/, '.result.txt');
6350
+ if (fs.existsSync(exact)) {
6351
+ return truncateText(fs.readFileSync(exact, 'utf8'), 4000);
6352
+ }
6353
+ const candidates = fs.existsSync(WORKER_LOG_DIR)
6354
+ ? fs.readdirSync(WORKER_LOG_DIR)
6355
+ .filter((name) => name.startsWith(parsed.ticketId + '-') && name.endsWith('.result.txt'))
6356
+ .map((name) => {
6357
+ const file = path.join(WORKER_LOG_DIR, name);
6358
+ const stat = fileStat(file);
6359
+ return stat ? { file, mtimeMs: stat.mtimeMs } : null;
6360
+ })
6361
+ .filter(Boolean)
6362
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
6363
+ : [];
6364
+ if (!candidates.length) {
6365
+ return null;
6366
+ }
6367
+ return truncateText(fs.readFileSync(candidates[0].file, 'utf8'), 4000);
6368
+ }
6369
+
6370
+ /** Recent worker transcripts, including runs whose live worker row was cleared after release/exit. */
6371
+ function recentWorkerRuns(limit = 20) {
6372
+ if (!fs.existsSync(WORKER_LOG_DIR)) {
6373
+ return [];
6374
+ }
6375
+
6376
+ const activeByLog = {};
6377
+ for (const agent of listLocalAgents()) {
6378
+ for (const worker of listWorkers(agent.agentId)) {
6379
+ if (worker && worker.logFile) {
6380
+ activeByLog[path.basename(worker.logFile)] = {
6381
+ ...worker,
6382
+ agentId: agent.agentId,
6383
+ label: agent.label || agent.agentId
6384
+ };
6385
+ }
6386
+ }
6387
+ }
6388
+
6389
+ return fs.readdirSync(WORKER_LOG_DIR)
6390
+ .map((name) => {
6391
+ const parsed = workerRunFromLogName(name);
6392
+ if (!parsed) {
6393
+ return null;
6394
+ }
6395
+ const logFile = path.join(WORKER_LOG_DIR, name);
6396
+ const stat = fileStat(logFile);
6397
+ if (!stat) {
6398
+ return null;
6399
+ }
6400
+ const active = activeByLog[name] || {};
6401
+ const alive = !!(active.pid && processIsAlive(active.pid));
6402
+ return {
6403
+ ticketId: parsed.ticketId,
6404
+ logName: name,
6405
+ logFile,
6406
+ size: stat.size,
6407
+ updatedAt: stat.mtime.toISOString(),
6408
+ startedAt: active.startedAt || (parsed.timestampMs ? new Date(parsed.timestampMs).toISOString() : null),
6409
+ agentId: active.agentId || null,
6410
+ label: active.label || null,
6411
+ agentType: active.agentType || null,
6412
+ pid: active.pid || null,
6413
+ alive,
6414
+ running: alive,
6415
+ attempt: active.attempt || null,
6416
+ workingDirectory: active.workingDirectory || null,
6417
+ targetBranch: active.targetBranch || null,
6418
+ suggestedWorktree: active.suggestedWorktree || null,
6419
+ workspace: active.workspace || null,
6420
+ subject: active.subject || null,
6421
+ result: resultTextForWorkerLog(logFile)
6422
+ };
6423
+ })
6424
+ .filter(Boolean)
6425
+ .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
6426
+ .slice(0, Math.max(1, Number(limit) || 20));
6427
+ }
6428
+
6429
+ /** The transcript of one specific worker log, addressed by safe basename. */
6430
+ function workerTranscriptByLogName(logName, fromOffset) {
6431
+ const parsed = workerRunFromLogName(path.basename(String(logName || '')));
6432
+ if (!parsed) {
6433
+ return { error: 'a valid worker log name is required' };
6434
+ }
6435
+ const logFile = path.join(WORKER_LOG_DIR, path.basename(logName));
6436
+ const transcript = readWorkerTranscript(logFile, fromOffset);
6437
+ return {
6438
+ ticketId: parsed.ticketId,
6439
+ logName: path.basename(logName),
6440
+ logFile,
6441
+ running: false,
6442
+ pid: null,
6443
+ follow: 'tail -f ' + logFile,
6444
+ ...transcript
6445
+ };
6446
+ }
6447
+
5788
6448
  function startDashboardServer(preferredPort) {
5789
6449
  return new Promise((resolve, reject) => {
5790
6450
  const sendDashboardError = (res, err, wantsJson = true) => {
@@ -5878,9 +6538,11 @@ function startDashboardServer(preferredPort) {
5878
6538
 
5879
6539
  if (req.method === 'GET' && requestUrl.pathname === '/api/worker-log') {
5880
6540
  try {
5881
- const payload = workerTranscriptFor(
5882
- requestUrl.searchParams.get('ticket'),
5883
- Number(requestUrl.searchParams.get('offset')) || 0);
6541
+ const offset = Number(requestUrl.searchParams.get('offset')) || 0;
6542
+ const logName = requestUrl.searchParams.get('log');
6543
+ const payload = logName
6544
+ ? workerTranscriptByLogName(logName, offset)
6545
+ : workerTranscriptFor(requestUrl.searchParams.get('ticket'), offset);
5884
6546
  res.statusCode = 200;
5885
6547
  res.setHeader('Content-Type', 'application/json');
5886
6548
  res.end(JSON.stringify(payload));
@@ -6340,6 +7002,7 @@ const AGENT_STATES = ['idle', 'working', 'paused'];
6340
7002
  // ---------------------------------------------------------------------------------------------
6341
7003
 
6342
7004
  const WORKER_LOG_DIR = path.join(SESSION_DIR, 'workers');
7005
+ const TICKET_WORKTREE_DIRNAME = 'ticket-worktrees';
6343
7006
  const AGENT_SERVE_POLL_MS = Number(process.env.REMITS_CLI_AGENT_SERVE_POLL_MS || 20000);
6344
7007
  const AGENT_SERVE_IDLE_POLL_MS = Number(process.env.REMITS_CLI_AGENT_SERVE_IDLE_POLL_MS || 60000);
6345
7008
  // After this many consecutive empty polls, back off to the idle cadence. An agent waiting all night
@@ -6608,6 +7271,57 @@ function resolveTicketWorkingDirectory(ticket, ownCwd = null) {
6608
7271
  return null;
6609
7272
  }
6610
7273
 
7274
+ function gitBranchNameInDirectory(cwd) {
7275
+ if (!cwd) return null;
7276
+ try {
7277
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', {
7278
+ cwd,
7279
+ stdio: ['ignore', 'pipe', 'ignore']
7280
+ }).toString().trim();
7281
+ return branch && branch !== 'HEAD' ? branch : null;
7282
+ } catch (_) {
7283
+ return null;
7284
+ }
7285
+ }
7286
+
7287
+ function defaultGitBranchInDirectory(cwd) {
7288
+ if (!cwd) return null;
7289
+ try {
7290
+ const branch = execSync('git symbolic-ref --short refs/remotes/origin/HEAD', {
7291
+ cwd,
7292
+ stdio: ['ignore', 'pipe', 'ignore']
7293
+ }).toString().trim().replace(/^[^/]+\//, '');
7294
+ return branch && branch !== 'HEAD' ? branch : null;
7295
+ } catch (_) {
7296
+ return null;
7297
+ }
7298
+ }
7299
+
7300
+ function ticketTargetBranch(ticket, baseDirectory) {
7301
+ const facts = (ticket && ticket.briefFacts) || {};
7302
+ const candidates = [
7303
+ facts.componentBranch,
7304
+ ticket && ticket.componentBranch,
7305
+ facts.branch,
7306
+ ticket && ticket.branch,
7307
+ defaultGitBranchInDirectory(baseDirectory),
7308
+ gitBranchNameInDirectory(baseDirectory)
7309
+ ];
7310
+ for (const candidate of candidates) {
7311
+ const branch = candidate == null ? '' : String(candidate).trim();
7312
+ if (branch && branch !== 'HEAD') return branch;
7313
+ }
7314
+ // No guess. This used to fall back to 'main', which is a guess presented to the worker as a fact — and
7315
+ // wrong on any repo whose default is `master`, or whose work belongs on a variant branch. A worker told
7316
+ // the wrong target branch does not fail; it does the work in the wrong place and reports success.
7317
+ // Absent is a real answer, and the brief omits the line rather than naming a branch nobody resolved.
7318
+ return null;
7319
+ }
7320
+
7321
+ function ticketWorktreePath(baseDirectory, ticketId) {
7322
+ return path.join(baseDirectory, '.remits-cli', TICKET_WORKTREE_DIRNAME, 'ticket-' + ticketId);
7323
+ }
7324
+
6611
7325
  /**
6612
7326
  * Which account this directory is a checkout OF, read from the directory itself.
6613
7327
  *
@@ -6718,6 +7432,21 @@ function processIsAlive(pid) {
6718
7432
  }
6719
7433
  }
6720
7434
 
7435
+ async function waitForProcessExit(pid, timeoutMs = 5000) {
7436
+ const numeric = Number(pid);
7437
+ if (!Number.isFinite(numeric) || numeric <= 0) {
7438
+ return true;
7439
+ }
7440
+ const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
7441
+ while (Date.now() < deadline) {
7442
+ if (!processIsAlive(numeric)) {
7443
+ return true;
7444
+ }
7445
+ await new Promise((resolve) => setTimeout(resolve, 100));
7446
+ }
7447
+ return !processIsAlive(numeric);
7448
+ }
7449
+
6721
7450
  /**
6722
7451
  * The locally-known agent sessions, dropping any whose anchor process has gone.
6723
7452
  *
@@ -6921,6 +7650,23 @@ function agentAccountClaims(cwd) {
6921
7650
  return claims;
6922
7651
  }
6923
7652
 
7653
+ /**
7654
+ * `--serves support,incident` -> ['support','incident'].
7655
+ *
7656
+ * Opaque strings: an account's workstream names are its own and neither the CLI nor the platform learns
7657
+ * what any of them means. Lower-cased and de-duplicated here so one machine's `--serves SDLC` and
7658
+ * another's `--serves sdlc` are the same declaration rather than two queues that never overlap.
7659
+ */
7660
+ function parseServedWorkstreams(value) {
7661
+ if (value === undefined || value === null || value === true) {
7662
+ return [];
7663
+ }
7664
+ return String(value)
7665
+ .split(',')
7666
+ .map((entry) => entry.trim().toLowerCase())
7667
+ .filter((entry, index, all) => entry && all.indexOf(entry) === index);
7668
+ }
7669
+
6924
7670
  async function agentRegisterCommand(flags, options = {}) {
6925
7671
  const cwd = process.cwd();
6926
7672
  try { updateAccountRepoIndex(cwd); } catch (_) { /* not in an account repo */ }
@@ -6964,6 +7710,15 @@ async function agentRegisterCommand(flags, options = {}) {
6964
7710
  // platform stops routing past it, so a supervisor at capacity is not handed work it cannot start.
6965
7711
  maxConcurrent: options.serve ? options.serve.maxConcurrent : (flags['max-concurrent'] ? parsePositiveInt(flags['max-concurrent'], 1) : undefined),
6966
7712
  autonomous: options.serve ? true : undefined,
7713
+ // The autonomy bound and the queues this session covers, on the PRESENCE record rather than only on
7714
+ // the work poll. Routing chooses from presence, so without these `dispatch(...)` would hand a code
7715
+ // change to a session launched read-only while an edit-capable one sat idle, and would ignore
7716
+ // `--serves` entirely. Both are omitted rather than sent empty when not declared, so a re-register
7717
+ // after a TTL lapse cannot silently un-specialise a machine.
7718
+ mode: options.serve ? options.serve.mode : undefined,
7719
+ serves: (options.serve && options.serve.serves && options.serve.serves.length)
7720
+ ? options.serve.serves
7721
+ : (parseServedWorkstreams(flags.serves).length ? parseServedWorkstreams(flags.serves) : undefined),
6967
7722
  state: flags.state && AGENT_STATES.includes(String(flags.state)) ? String(flags.state) : 'idle',
6968
7723
  activity: flags.activity || null,
6969
7724
  source: flags.source || 'repo'
@@ -7001,6 +7756,7 @@ async function agentRegisterCommand(flags, options = {}) {
7001
7756
  if (options.serve) {
7002
7757
  entry.workerAgent = options.serve.workerAgent;
7003
7758
  entry.mode = options.serve.mode;
7759
+ entry.serves = options.serve.serves || [];
7004
7760
  entry.maxConcurrent = options.serve.maxConcurrent;
7005
7761
  }
7006
7762
  rememberLocalAgent(entry);
@@ -7036,6 +7792,9 @@ async function agentRegisterCommand(flags, options = {}) {
7036
7792
  console.log(' worker ' + serve.workerAgent + ' (' + serve.mode + ' mode)');
7037
7793
  console.log(' ' + serve.workerAgentWhy);
7038
7794
  console.log(' capacity ' + serve.maxConcurrent + ' ticket(s) at a time');
7795
+ console.log(' serves ' + ((serve.serves && serve.serves.length)
7796
+ ? serve.serves.join(', ') + ' (only these workstreams are routed here, and only these are swept)'
7797
+ : 'every workstream on these accounts'));
7039
7798
  console.log('');
7040
7799
  console.log('When a ticket is routed here it launches a fresh ' + serve.workerAgent + ' process for that');
7041
7800
  console.log('ticket, in that account\'s repo, and that process exits when the ticket is done — so no');
@@ -7079,6 +7838,16 @@ async function agentServeCommand(flags) {
7079
7838
  }
7080
7839
  const mode = String(flags.mode || (flagEnabled(flags['investigate-only']) ? 'investigate' : 'edit'))
7081
7840
  .toLowerCase() === 'investigate' ? 'investigate' : 'edit';
7841
+ const serves = parseServedWorkstreams(flags.serves);
7842
+ if (serves.length) {
7843
+ console.log('');
7844
+ console.log('Serving workstreams: ' + serves.join(', '));
7845
+ console.log(' This session is offered ONLY tickets in those workstreams, and its sweep takes only');
7846
+ console.log(' those. A workstream no live session covers is routed to nobody — the platform reports');
7847
+ console.log(' that as no_agent_serves_workstream rather than quietly handing it here anyway.');
7848
+ console.log(' Drop --serves to serve every workstream on these accounts.');
7849
+ console.log('');
7850
+ }
7082
7851
 
7083
7852
  // Say what this supervisor CANNOT work, before it starts rather than one ticket at a time. A worker
7084
7853
  // pointed at an account with no usable checkout parks correctly, but the operator only learns that
@@ -7114,6 +7883,7 @@ async function agentServeCommand(flags) {
7114
7883
  workerAgent,
7115
7884
  workerAgentWhy: choice.why,
7116
7885
  mode,
7886
+ serves,
7117
7887
  maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1))
7118
7888
  }
7119
7889
  });
@@ -7188,7 +7958,11 @@ async function agentWorkersCommand(flags) {
7188
7958
  console.log(' ' + (worker.subject || '(no subject)'));
7189
7959
  console.log(' since ' + worker.startedAt + ' attempt ' + (worker.attempt || 1));
7190
7960
  console.log(' in ' + (worker.workingDirectory || '(no repo resolved)') +
7961
+ (worker.targetBranch ? ' target branch ' + worker.targetBranch : '') +
7191
7962
  (worker.workspace ? ' workspace ' + worker.workspace : ''));
7963
+ if (worker.suggestedWorktree) {
7964
+ console.log(' suggest ' + worker.suggestedWorktree);
7965
+ }
7192
7966
  console.log(' doing ' + (worker.activity || 'nothing reported yet'));
7193
7967
  console.log(' log ' + worker.logFile);
7194
7968
  }
@@ -7225,6 +7999,13 @@ function startAgentHeartbeatDaemon(entry, serve = null) {
7225
7999
  '--worker-agent', serve.workerAgent,
7226
8000
  '--mode', serve.mode,
7227
8001
  '--max-concurrent', String(serve.maxConcurrent));
8002
+ // The daemon is DETACHED: nothing about its process tree recovers what the operator typed, so any
8003
+ // declaration that must survive into the supervisor loop has to travel on the command line. Omitted
8004
+ // when empty so the daemon's own `parseServedWorkstreams` sees "not declared" rather than "declared
8005
+ // as nothing", which are different facts on the registration payload.
8006
+ if (serve.serves && serve.serves.length) {
8007
+ args.push('--serves', serve.serves.join(','));
8008
+ }
7228
8009
  }
7229
8010
  const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', env: { ...process.env } });
7230
8011
  child.unref();
@@ -7404,8 +8185,8 @@ async function postAgentStatus(context, agentId, update) {
7404
8185
  * Launch one headless agent process for one ticket, and see it through to its exit.
7405
8186
  *
7406
8187
  * The brief comes from the platform ({@code SupportTicketBrief}) so all three agent types are told
7407
- * the same thing; only the working directory is resolved here, because only this machine knows
7408
- * where the account is checked out.
8188
+ * the same thing. Only the local supervisor can add machine-local repository hints; the worker owns the
8189
+ * final grounding step, including creating or choosing a git worktree before it edits.
7409
8190
  */
7410
8191
  function spawnTicketWorker(context, ctx, ticket) {
7411
8192
  const { spawn } = require('child_process');
@@ -7420,18 +8201,61 @@ function spawnTicketWorker(context, ctx, ticket) {
7420
8201
  return { pid: null, done: Promise.resolve({ ticketId, ok: false, reason: 'unsupported_agent' }) };
7421
8202
  }
7422
8203
 
7423
- const workingDirectory = resolveTicketWorkingDirectory(ticket, ctx && ctx.cwd);
8204
+ const baseWorkingDirectory = resolveTicketWorkingDirectory(ticket, ctx && ctx.cwd);
8205
+ const repositoryWork = !((ticket && ticket.requiresRepositoryWork === false) ||
8206
+ (ticket && ticket.briefFacts && ticket.briefFacts.requiresRepositoryWork === false));
8207
+ const targetBranch = repositoryWork ? ticketTargetBranch(ticket, baseWorkingDirectory) : gitBranchNameInDirectory(baseWorkingDirectory);
8208
+ const suggestedWorktree = repositoryWork && baseWorkingDirectory ? ticketWorktreePath(baseWorkingDirectory, ticketId) : null;
8209
+ const workingDirectory = baseWorkingDirectory;
7424
8210
  // The brief is rendered server-side without the local path (the platform cannot know it), so the
7425
- // resolved directory is appended here. When nothing resolved, the server-rendered brief already
8211
+ // resolved local repository hint is appended here. When nothing resolved, the server-rendered brief already
7426
8212
  // tells the worker to find it rather than guess — do not paper over that with a default.
7427
8213
  // The staging lane is set BY THIS FUNCTION (REMITS_WORKSPACE below) and the worker has no other way to
7428
8214
  // learn it: `components status` would show it a lane it never chose, and anything it reports about "the
7429
8215
  // staged code" is ambiguous until it knows which lane it means. Telling it is one line; making it guess
7430
8216
  // is the whole class of defect the lane exists to remove.
7431
8217
  const workerWorkspace = 'ticket-' + ticketId;
7432
- const brief = String(ticket.brief || '') + (workingDirectory
7433
- ? '\n\n## Resolved working directory\n\n`' + workingDirectory + '`\n'
7434
- : '') +
8218
+ // A branch nobody resolved is OMITTED, never guessed: a worker told the wrong target branch does not
8219
+ // fail, it does the work in the wrong place and reports success.
8220
+ const localWorktreeText = workingDirectory && repositoryWork
8221
+ ? '\n\n## Local repository grounding\n\n' +
8222
+ 'The supervisor resolved the repository account to this local checkout:\n\n`' + workingDirectory + '`\n\n' +
8223
+ (targetBranch
8224
+ ? 'Target git/component branch: `' + targetBranch + '`.\n'
8225
+ : 'No target branch resolved. Read `remits-cli ticket where --ticket ' + ticketId + '` and the\n' +
8226
+ 'repository itself before you decide which branch this work belongs on — do not assume `main`.\n') +
8227
+ 'Suggested per-ticket worktree path: `' + suggestedWorktree + '`.\n\n' +
8228
+ 'Before editing, establish your own isolated git worktree for this ticket and move into it, so the\n' +
8229
+ 'operator\'s launch checkout is not modified underneath them. **You own this decision** — the\n' +
8230
+ 'supervisor is a plain local process and cannot reason about repository state. The command below is\n' +
8231
+ 'a starting point, not a script to follow blindly; it must start from the remote branch tip, not a\n' +
8232
+ 'stale local ref:\n\n' +
8233
+ '```\n' +
8234
+ (targetBranch
8235
+ ? 'git -C ' + shellQuote(workingDirectory) + ' fetch origin ' + shellQuote(targetBranch) + '\n'
8236
+ : 'git -C ' + shellQuote(workingDirectory) + ' fetch origin\n') +
8237
+ 'git -C ' + shellQuote(workingDirectory) + ' worktree add --force ' + shellQuote(suggestedWorktree) +
8238
+ (targetBranch ? ' ' + targetBranch : ' <branch>') + '\n' +
8239
+ 'cd ' + shellQuote(suggestedWorktree) + '\n' +
8240
+ (targetBranch
8241
+ ? 'git pull --ff-only origin ' + shellQuote(targetBranch) + '\n' +
8242
+ 'git rev-list --left-right --count HEAD...origin/' + shellQuote(targetBranch) + ' # must print: 0 0\n'
8243
+ : 'git pull --ff-only origin <branch>\n' +
8244
+ 'git rev-list --left-right --count HEAD...origin/<branch> # must print: 0 0\n') +
8245
+ 'remits-cli components status\n' +
8246
+ '```\n\n' +
8247
+ 'If the pull cannot fast-forward, or either side of that rev-list count is non-zero, stop and settle\n' +
8248
+ 'the branch before editing. A worktree and workspace isolate this worker from the launch checkout and\n' +
8249
+ 'from other staging lanes; they do not prove which commit the files are based on.\n\n' +
8250
+ 'If git still refuses because the branch is already checked out elsewhere, add `--detach`, and in THAT case\n' +
8251
+ 'only, export `REMITS_GIT_BRANCH=' + (targetBranch || '<branch>') + '` so the branch this run reports\n' +
8252
+ '(staging scope, landing lease, run context) matches the branch it is working — git cannot name a\n' +
8253
+ 'branch for a detached worktree, so nothing else can answer. On a normal worktree do not set it and\n' +
8254
+ 'do not need to: git is asked first and the variable is ignored whenever git has a real answer.\n'
8255
+ : (workingDirectory
8256
+ ? '\n\n## Resolved local repository\n\n`' + workingDirectory + '`\n'
8257
+ : '');
8258
+ const brief = String(ticket.brief || '') + localWorktreeText +
7435
8259
  '\n\n## Your staging lane\n\n' +
7436
8260
  'Every `remits-cli` command you run stages into the **`' + workerWorkspace + '`** workspace lane, not the\n' +
7437
8261
  'shared one. This is set for you; you do not need to pass `--workspace`. It means your staged components\n' +
@@ -7446,6 +8270,12 @@ function spawnTicketWorker(context, ctx, ticket) {
7446
8270
  // worker spent its run fighting a restriction nobody had mentioned. The platform now returns the phase
7447
8271
  // it derived from the repository's edit lease, and that is the authority. The session's own --mode can
7448
8272
  // only narrow it: an operator who asked for a read-only agent gets one even when the lease is free.
8273
+ //
8274
+ // `working` is a THIRD phase, and it must fall on the edit side of this branch: it means the account
8275
+ // declared that this ticket touches no repository, so no lease was taken and none was refused. A run
8276
+ // like that is not restricted — it simply has no checkout to be exclusive about. Testing for
8277
+ // `!== 'investigating'` rather than `=== 'editing'` is what keeps a new phase from silently demoting
8278
+ // every non-repository worker to read-only.
7449
8279
  const effectiveMode = (ctx.mode === 'investigate' || ticket.phase === 'investigating')
7450
8280
  ? 'investigate'
7451
8281
  : 'edit';
@@ -7498,15 +8328,22 @@ function spawnTicketWorker(context, ctx, ticket) {
7498
8328
  sandbox: built.sandbox || null,
7499
8329
  startedAt: new Date().toISOString(),
7500
8330
  workingDirectory: workingDirectory || null,
8331
+ targetBranch: targetBranch || null,
8332
+ suggestedWorktree: suggestedWorktree || null,
7501
8333
  accountId: ticket.accountId || null,
7502
8334
  subject: ticket.subject || null,
7503
8335
  logFile
7504
8336
  });
7505
8337
  appendGlobalActivityLog('agent', 'worker.started', {
7506
8338
  agentId, ticketId, agentType, pid: child.pid, attempt, workingDirectory: workingDirectory || null,
8339
+ targetBranch: targetBranch || null,
8340
+ suggestedWorktree: suggestedWorktree || null,
7507
8341
  // Which phase a run started in is the first thing anyone asks when its output looks thin, so it is
7508
8342
  // on the record rather than inferable from a sandbox flag nobody logged.
7509
- phase: effectiveMode === 'investigate' ? 'investigating' : 'editing',
8343
+ // Report the phase the PLATFORM derived, not the one this launcher collapsed it to: an operator
8344
+ // reading `editing` on a ticket that never involved a repository would go looking for a lease that
8345
+ // was correctly never taken.
8346
+ phase: effectiveMode === 'investigate' ? 'investigating' : (ticket.phase || 'editing'),
7510
8347
  editLease: ticket.editLease ? { agentId: ticket.editLease.agentId, ticketId: ticket.editLease.ticketId } : null
7511
8348
  });
7512
8349
  if (ticket.phase === 'investigating' && ticket.editLeaseMessage) {
@@ -7637,6 +8474,7 @@ async function agentHeartbeatDaemonCommand(flags) {
7637
8474
  // worker kind must come from the flag it was started with, never from re-detection.
7638
8475
  workerAgent: workerAgentType(flags['worker-agent'] || flags.agent),
7639
8476
  mode: String(flags.mode || 'edit').toLowerCase() === 'investigate' ? 'investigate' : 'edit',
8477
+ serves: parseServedWorkstreams(flags.serves),
7640
8478
  maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1)),
7641
8479
  runId: 'run-' + Date.now().toString(36)
7642
8480
  };
@@ -7742,6 +8580,31 @@ async function agentHeartbeatDaemonCommand(flags) {
7742
8580
  });
7743
8581
  }
7744
8582
 
8583
+ // A supervisor sitting idle beside a full queue is either broken or doing exactly what it was
8584
+ // told, and those look identical from outside. Logged ONCE per stretch of empty polls, because a
8585
+ // reason repeated every few seconds is a log nobody reads.
8586
+ const sweep = (response && response.sweep) || {};
8587
+ if (emptyPolls === 0) {
8588
+ if ((sweep.needsHumanTicketIds || []).length) {
8589
+ appendGlobalActivityLog('agent', 'serve.needs_human', {
8590
+ agentId,
8591
+ ticketIds: sweep.needsHumanTicketIds,
8592
+ message: sweep.needsHumanTicketIds.length + ' ticket(s) have been through this account\'s ' +
8593
+ 'autonomous-run limit (' + sweep.autonomousRunLimit + ') and are no longer offered to ' +
8594
+ 'workers. Answer, reopen or route one by hand: remits-cli ticket queue --min-runs 1'
8595
+ }, 'warn');
8596
+ }
8597
+ if ((sweep.outOfScopeTicketIds || []).length) {
8598
+ appendGlobalActivityLog('agent', 'serve.out_of_scope', {
8599
+ agentId,
8600
+ ticketIds: sweep.outOfScopeTicketIds,
8601
+ message: sweep.outOfScopeTicketIds.length + ' unrouted ticket(s) are outside the workstreams ' +
8602
+ 'this session serves (' + (sweep.serves || []).join(', ') + '). That is the declaration ' +
8603
+ 'working; another session has to cover them.'
8604
+ });
8605
+ }
8606
+ }
8607
+
7745
8608
  for (const ticket of (response && response.tickets) || []) {
7746
8609
  const ticketId = String(ticket.id || ticket.ticketId || '');
7747
8610
  if (!ticketId || inFlight.has(ticketId)) {
@@ -7772,7 +8635,12 @@ async function agentHeartbeatDaemonCommand(flags) {
7772
8635
  'max-concurrent': String(ctx.maxConcurrent),
7773
8636
  json: 'true'
7774
8637
  },
7775
- { spawnHeartbeat: false }
8638
+ // Re-register with THIS supervisor's declared bound and queues. The platform treats an absent
8639
+ // `mode`/`serves` as "unchanged", which is right for a heartbeat against a live record and wrong
8640
+ // here: this path runs precisely because the record EXPIRED, so there is nothing left to be
8641
+ // unchanged from, and a specialised machine would come back as a generalist that can edit.
8642
+ { spawnHeartbeat: false,
8643
+ serve: { mode: ctx.mode, serves: ctx.serves, maxConcurrent: ctx.maxConcurrent } }
7776
8644
  ).catch((err) => {
7777
8645
  appendGlobalActivityLog('agent', 'heartbeat.reregister_failed',
7778
8646
  { agentId, error: describeError(err) }, 'error');
@@ -7908,6 +8776,28 @@ async function agentWorkCommand(flags) {
7908
8776
  * record, so the same commands work on every Remits platform — which is what an autonomous worker
7909
8777
  * needs, since its brief has to be written before anyone knows which platform it will run on.
7910
8778
  */
8779
+ /**
8780
+ * Say that this ticket is governed, and say it loudly when the policy could not be parsed.
8781
+ *
8782
+ * A worker that only ever learns a rule exists by being refused by it has already wasted the call that
8783
+ * hit it — the same argument that inlines the rules into a worker brief instead of pointing at them.
8784
+ */
8785
+ function printTicketPolicy(response) {
8786
+ const policy = response && response.policy;
8787
+ if (!policy) return;
8788
+ if (policy.error) {
8789
+ console.error('POLICY NOT ENFORCED: this account has support-ticket rules that could not be parsed: ' +
8790
+ policy.error);
8791
+ console.error(' Report this on the ticket. Do not read the absence of a refusal as permission.');
8792
+ return;
8793
+ }
8794
+ if (policy.ruleCount) {
8795
+ console.log('Policy: ' + policy.ruleCount +
8796
+ (policy.ruleCount === 1 ? ' rule governs' : ' rules govern') +
8797
+ ' this ticket (' + (policy.ruleIds || []).join(', ') + ').');
8798
+ }
8799
+ }
8800
+
7911
8801
  async function ticketCommand(flags, subcommand) {
7912
8802
  const action = String(subcommand || 'read').toLowerCase();
7913
8803
 
@@ -7987,6 +8877,15 @@ async function ticketCommand(flags, subcommand) {
7987
8877
  size: planningFlagValue(flags.size),
7988
8878
  blockedBy: planningFlagValue(firstDefined(flags['blocked-by'], flags.blockedBy)),
7989
8879
  workstream: planningFlagValue(flags.workstream),
8880
+ // Relations and commitments clear the same way a planning slot does — the flag with no value — so
8881
+ // `--caused-by` alone detaches a ticket from its cluster rather than being read as "no change".
8882
+ duplicateOf: planningFlagValue(firstDefined(flags['duplicate-of'], flags.duplicateOf)),
8883
+ causedBy: planningFlagValue(firstDefined(flags['caused-by'], flags.causedBy)),
8884
+ dueAt: planningFlagValue(firstDefined(flags['due-at'], flags.due, flags.dueAt)),
8885
+ respondBy: planningFlagValue(firstDefined(flags['respond-by'], flags.respondBy)),
8886
+ // Tri-state on the wire: 'true'/'false' are the two answers and absent is "not saying", which the
8887
+ // platform reads as "keeps today's behaviour". Coercing here would erase the third state.
8888
+ requiresRepositoryWork: firstDefined(flags['repo-work'], flags['requires-repository-work']),
7990
8889
  tags: flags.tags ? String(flags.tags).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
7991
8890
  label: flags.label,
7992
8891
  url: flags.url,
@@ -8013,6 +8912,11 @@ async function ticketCommand(flags, subcommand) {
8013
8912
  agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined,
8014
8913
  runId: flags['run-id'] || process.env.REMITS_WORKER_RUN_ID || undefined,
8015
8914
  agentLabel: flags['agent-label'] || undefined,
8915
+ // The acknowledgement that gets a caller past a `require` rule. It is deliberately NOT enough on its
8916
+ // own: the platform honours it only for an actor the rule permits, and it decides "human" from the
8917
+ // ABSENCE of a run identity — which a worker process always sends. So a headless worker passing this
8918
+ // is refused, and told so. The override, when it is honoured, is written to the ticket worklog.
8919
+ acknowledge: flags.acknowledge || undefined,
8016
8920
  // WHERE this run is working, sent with every lifecycle call so the lease and the claim record THIS
8017
8921
  // process's checkout rather than the supervisor's. A ticket worker runs in its own workspace lane
8018
8922
  // (REMITS_WORKSPACE=ticket-<id>) and may run in its own worktree, so the two genuinely differ.
@@ -8045,6 +8949,8 @@ async function ticketCommand(flags, subcommand) {
8045
8949
  return;
8046
8950
  }
8047
8951
 
8952
+ printTicketPolicy(response);
8953
+
8048
8954
  if (flagEnabled(flags.json) || action === 'read' || action === 'deliveries') {
8049
8955
  console.log(JSON.stringify(response, null, 2));
8050
8956
  return;
@@ -8161,6 +9067,20 @@ async function ticketQueueCommand(flags) {
8161
9067
  boardStage: flags['board-stage'] || flags.boardStage,
8162
9068
  blockedBy: flags['blocked-by'] || flags.blockedBy,
8163
9069
  search: flags.search || flags.q,
9070
+ // Every one of these reads a field the platform already mirrored and already projected, and that
9071
+ // no surface could ask about. `syncState` in particular is how a broken integration is found:
9072
+ // a failed sync retries forever, so it sits in pendingSync looking exactly like a healthy one.
9073
+ syncStates: csv(flags['sync-state'] || flags.syncState),
9074
+ overdue: flagEnabled(flags.overdue) || undefined,
9075
+ relatedTo: flags['related-to'] || flags.relatedTo,
9076
+ duplicateOf: flags['duplicate-of'] || flags.duplicateOf,
9077
+ causedBy: flags['caused-by'] || flags.causedBy,
9078
+ hasCause: flagEnabled(flags['has-cause'] || flags.hasCause) || undefined,
9079
+ dueBefore: flags['due-before'] || flags.dueBefore,
9080
+ dueAfter: flags['due-after'] || flags.dueAfter,
9081
+ // "Which tickets has the fleet already tried and handed back." A mirrored field with no filter is a
9082
+ // mirrored field nobody can act on — the same defect awaitingResponse and externalSyncState each had.
9083
+ minAutonomousRuns: parsePositiveInt(flags['min-runs'] || flags.minRuns, 0) || undefined,
8164
9084
  sortBy: flags['sort-by'] || flags.sortBy,
8165
9085
  sortDirection: flags['sort-direction'] || flags.sortDirection,
8166
9086
  includeChildren: flags['include-children'] === 'false' ? false : undefined,
@@ -8181,7 +9101,15 @@ async function ticketQueueCommand(flags) {
8181
9101
  console.log(String(response.totalCount != null ? response.totalCount : tickets.length) +
8182
9102
  ' ticket(s) — ' + (counts.unrouted || 0) + ' unrouted, ' + (counts.unassigned || 0) + ' unassigned' +
8183
9103
  ', ' + (counts.awaitingResponse || 0) + ' waiting on a human' +
9104
+ ((counts.needsHuman || 0) ? ', ' + counts.needsHuman + ' NEEDS A PERSON' : '') +
8184
9105
  ' [' + (response.dataMode || '?') + ' lane]');
9106
+ if (counts.needsHuman) {
9107
+ // The one count that measures a backlog the automation deliberately created. Without it the fleet
9108
+ // reports healthy activity while the queue quietly stops going down.
9109
+ console.log(' ' + counts.needsHuman + ' ticket(s) have been through this account\'s autonomous-run limit ' +
9110
+ 'and are no longer offered to workers.');
9111
+ console.log(' Find them: remits-cli ticket queue --min-runs 1 --unassigned (then answer, reopen, or route one by hand)');
9112
+ }
8185
9113
  console.log('');
8186
9114
  // Board position is printed only when some ticket in this queue actually has one. An always-on column
8187
9115
  // would be blank for every account that does not run a board, and a blank column reads as missing data.
@@ -8199,6 +9127,7 @@ async function ticketQueueCommand(flags) {
8199
9127
  // and truncating `awaiting_customer_confirmation` to fit a column makes it unrecognisable.
8200
9128
  const marks = [
8201
9129
  t.awaitingResponseSince ? 'WAITING ON A HUMAN' : '',
9130
+ t.autonomousRuns > 1 ? 'run ' + t.autonomousRuns + ' by workers' : '',
8202
9131
  showsBoard && t.boardStage ? 'stage ' + t.boardStage : '',
8203
9132
  showsBoard && t.plannedIn ? 'in ' + t.plannedIn : '',
8204
9133
  showsBoard && t.blockedBy ? 'BLOCKED BY ' + t.blockedBy : ''
@@ -8367,6 +9296,19 @@ function printTicketHelp() {
8367
9296
  console.log(' --planned-in --blocked-by --sort-by --sort-direction --limit --json');
8368
9297
  console.log(' --awaiting-response lists only tickets parked on a human. A status filter cannot');
8369
9298
  console.log(' ask that: `ask` parks a ticket in pending_review, which also means "done, review me".');
9299
+ console.log(' --overdue lists tickets past a due time their account committed to; --sort-by due');
9300
+ console.log(' ranks by it (soonest first; undated sorts last, in BOTH directions).');
9301
+ console.log(' --sync-state failed finds tickets that cannot reach the system this account syncs');
9302
+ console.log(' with. A failed sync retries forever, so it is indistinguishable from a healthy');
9303
+ console.log(' pending one in `pendingSync` — this is how a broken integration is found before the');
9304
+ console.log(' partner reports it.');
9305
+ console.log(' --related-to ID lists the cluster around one ticket; --has-cause lists every ticket');
9306
+ console.log(' that defers to another one.');
9307
+ console.log(' --min-runs N finds tickets autonomous workers have already picked up N times and');
9308
+ console.log(' handed back. Past this account\'s limit the sweep stops offering them, so they are a');
9309
+ console.log(' backlog the automation deliberately created and nothing else surfaces: answer one');
9310
+ console.log(' (`ticket answer`), reopen it, or route it by hand — an explicit route is never');
9311
+ console.log(' vetoed by the limit.');
8370
9312
  console.log(' create --subject "..." --type defect|question|task|incident|enhancement');
8371
9313
  console.log(' Raise a ticket. Use it when you find a SECOND problem while working one — file it');
8372
9314
  console.log(' rather than widening the ticket you were given. [--priority --description --tags');
@@ -8407,8 +9349,9 @@ function printTicketHelp() {
8407
9349
  console.log(' reopen | assign --assignee EMAIL');
8408
9350
  console.log(' route --agent AGENT_ID Hand it to a registered agent session.');
8409
9351
  console.log(' unroute Clear its routing, e.g. when that session is gone.');
8410
- console.log(' tag --tags a,b Mark it. The cheapest way to make a cluster of');
8411
- console.log(' near-identical tickets visible as one cluster to whoever looks next.');
9352
+ console.log(' tag --tags a,b Mark it, for a HUMAN reading the queue. When the');
9353
+ console.log(' tickets genuinely share a cause, use `relate` instead or as well: the platform reads');
9354
+ console.log(' that one, and a tag it cannot read will not stop a cluster being worked N times.');
8412
9355
  console.log(' artifact --type TYPE --label "..." [--url U | --content "..."]');
8413
9356
  console.log(' Attach evidence — a log, a query result, a repro. Better than pasting it into a');
8414
9357
  console.log(' worklog summary, where it is neither typed nor retrievable.');
@@ -8420,6 +9363,20 @@ function printTicketHelp() {
8420
9363
  console.log(' planning [--workstream V] [--planned-in V] [--board-stage V] [--rank N] [--size V] [--blocked-by V]');
8421
9364
  console.log(' Update organization-owned planning fields without changing platform lifecycle status.');
8422
9365
  console.log(' Pass a flag with no value to CLEAR that slot, e.g. --blocked-by');
9366
+ console.log(' relate [--caused-by ID | --duplicate-of ID] [--reason "..."]');
9367
+ console.log(' Say this ticket is part of a CLUSTER: N tickets, N requesters, one root cause.');
9368
+ console.log(' Unlike a tag, the platform READS it — a ticket whose cause is being worked is not');
9369
+ console.log(' handed out again while that work runs, so a cluster costs one worker and not N.');
9370
+ console.log(' --duplicate-of: no separate work at all. --caused-by: this requester still needs');
9371
+ console.log(' their own outcome once the cause is settled. A flag with no value clears it.');
9372
+ console.log(' due --due-at 2026-09-08T17:00:00Z [--respond-by ...]');
9373
+ console.log(' Record the times YOUR organization committed to. The platform stores, sorts and');
9374
+ console.log(' filters these and never acts on them: no timer, no escalation. Build those as an');
9375
+ console.log(' Action sweeping `ticket queue --overdue`.');
9376
+ console.log(' repo-work --repo-work false');
9377
+ console.log(' Declare that this ticket does not involve the repository — a documented answer, or');
9378
+ console.log(' something only a person can do. A worker then takes no repository edit lease for it');
9379
+ console.log(' and blocks nobody who needs the checkout. Absent means it DOES; nothing is inferred.');
8423
9380
  console.log(' force-unlease [--reason "..."] Break a stale repository lease somebody else holds.');
8424
9381
  console.log(' For an operator only: unlease returns YOUR lease, this one takes away theirs, and it');
8425
9382
  console.log(' records a handoff worklog entry on the ticket so the intervention is visible.');
@@ -8458,6 +9415,23 @@ function runContextPayload(flags = {}) {
8458
9415
  * and they are opposite facts: the shared lane is exactly the one where concurrent agents overwrite
8459
9416
  * each other's staged components.
8460
9417
  */
9418
+ /**
9419
+ * What a session was launched to do: the workstreams it covers and whether it may edit.
9420
+ *
9421
+ * Returns null for an unrestricted, edit-capable session — the default — so the line only ever appears
9422
+ * when there IS a restriction to know about. A line that renders for everybody is a line nobody reads.
9423
+ */
9424
+ function describeAgentScope(record) {
9425
+ if (!record) return null;
9426
+ const serves = Array.isArray(record.serves) ? record.serves.filter(Boolean) : [];
9427
+ const readOnly = record.mode === 'investigate' || record.canEdit === false;
9428
+ if (!serves.length && !readOnly) return null;
9429
+ const parts = [];
9430
+ parts.push(serves.length ? 'serves ' + serves.join(', ') : 'serves every workstream');
9431
+ parts.push(readOnly ? 'investigate only (never edits or stages)' : 'may edit');
9432
+ return parts.join(' · ');
9433
+ }
9434
+
8461
9435
  function describeRunLocation(record) {
8462
9436
  if (!record) return '';
8463
9437
  const dir = record.workingDirectory || record.cwd || record.claimedWorkingDirectory;
@@ -8533,6 +9507,11 @@ async function agentWorkMapCommand(flags) {
8533
9507
  (agent.secondsSinceSeen != null ? ' seen ' + agent.secondsSinceSeen + 's ago' : ''));
8534
9508
  const at = describeRunLocation(agent);
8535
9509
  if (at) console.log(' ' + at);
9510
+ // What this session is FOR, beside where it is. An operator reading a map of idle machines beside a
9511
+ // full queue needs to see that they are idle BY DECLARATION — read-only, or serving other
9512
+ // workstreams — not that routing is broken.
9513
+ const bound = describeAgentScope(agent);
9514
+ if (bound) console.log(' ' + bound);
8536
9515
  if (agent.activity) console.log(' ' + String(agent.activity).slice(0, 100));
8537
9516
  }
8538
9517
  console.log('');
@@ -8590,6 +9569,7 @@ async function agentListCommand(flags) {
8590
9569
  console.log(' lane ' + (agent.dataMode || 'prod') +
8591
9570
  ' capacity ' + (agent.activeTicketCount || 0) + '/' + (agent.maxConcurrent || 1) +
8592
9571
  (agent.hasCapacity === false ? ' (at capacity — not routable)' : ''));
9572
+ console.log(' serves ' + (describeAgentScope(agent) || 'every workstream, and may edit'));
8593
9573
  if (agent.activity) console.log(' doing ' + agent.activity);
8594
9574
  // Through describeRunLocation, which reads BOTH the canonical `workingDirectory` and the historical
8595
9575
  // `cwd`: a record written by an older platform, or still in Redis from before this field was
@@ -8715,6 +9695,7 @@ async function agentReleaseCommand(flags) {
8715
9695
  if (daemonPid) {
8716
9696
  workersStopped = listWorkers(agentId).length;
8717
9697
  try { process.kill(daemonPid, 'SIGTERM'); } catch (_) { /* already gone */ }
9698
+ await waitForProcessExit(daemonPid, 5000);
8718
9699
  }
8719
9700
 
8720
9701
  const context = agentSessionFor(flags, local);
@@ -8750,6 +9731,7 @@ function printAgentHelp() {
8750
9731
  console.log('Usage: remits-cli agent <subcommand>');
8751
9732
  console.log('');
8752
9733
  console.log(' serve [--worker-agent claude|codex|gemini] [--max-concurrent N] [--mode edit|investigate]');
9734
+ console.log(' [--serves support,incident,sdlc]');
8753
9735
  console.log(' Run this in a PLAIN TERMINAL TAB — that tab becomes the agent host. It returns');
8754
9736
  console.log(' immediately; a background supervisor launches ONE fresh headless agent process per');
8755
9737
  console.log(' routed ticket, in that account\'s repo, and that process exits when the ticket is');
@@ -8760,10 +9742,17 @@ function printAgentHelp() {
8760
9742
  console.log(' session is running (when you start it from inside one), else your');
8761
9743
  console.log(' `remits-cli config set --agent NAME` default. It prints which it chose and why.');
8762
9744
  console.log(' --mode investigate runs workers read-only (no file edits).');
9745
+ console.log(' --serves declares which WORKSTREAMS this machine covers, so a fleet can be a set of');
9746
+ console.log(' queues rather than interchangeable machines: --serves sdlc on one box, --serves');
9747
+ console.log(' support,incident on another. Only those tickets are routed here and only those are');
9748
+ console.log(' swept. Omit it to serve everything, which is the default. A workstream no live');
9749
+ console.log(' session covers goes unrouted and is reported as no_agent_serves_workstream — the');
9750
+ console.log(' declaration is honoured rather than quietly overridden, so check `agent map` after');
9751
+ console.log(' specialising a fleet.');
8763
9752
  console.log('');
8764
9753
  console.log(' workers [--json] What this session is running right now.');
8765
9754
  console.log('');
8766
- console.log(' register [--label NAME] [--data-mode test|prod] [--base-url URL]');
9755
+ console.log(' register [--label NAME] [--data-mode test|prod] [--base-url URL] [--serves a,b]');
8767
9756
  console.log(' Presence only — make THIS session routable without starting anything. Use it when a');
8768
9757
  console.log(' human, or the agent in this very tab, will work the tickets by hand. Runs from ANY');
8769
9758
  console.log(' directory — it registers for every account repo indexed on this machine that your');
@@ -9202,6 +10191,13 @@ function printComponentsHelp(subcommand) {
9202
10191
  console.log('--summary prints compact counts, removals/tombstones, errors, skipped items, and warnings.');
9203
10192
  console.log('');
9204
10193
  console.log('Agent safety gates (each exits non-zero instead of printing a wall of JSON):');
10194
+ console.log(' --safe THE RECOMMENDED PATH on a variant branch. Expands to');
10195
+ console.log(' --summary --changed-only --fail-on-errors --fail-on-removed,');
10196
+ console.log(' resolves --changed-since from the merge base with trunk when you');
10197
+ console.log(' did not name one (local refs only, no implicit fetch), and prints');
10198
+ console.log(' the planned writes before mutating unless --yes is passed.');
10199
+ console.log(' On TRUNK there is no plan to gate, so it explains what a trunk');
10200
+ console.log(' reconcile does and requires --yes.');
9205
10201
  console.log(' --changed-only dry-run first; fail unless every planned write is a component this checkout changed');
9206
10202
  console.log(' --changed-since <ref> pair with --changed-only after committing: the changed set becomes');
9207
10203
  console.log(' the components touched between <ref> and HEAD, plus uncommitted edits');
@@ -9213,14 +10209,29 @@ function printComponentsHelp(subcommand) {
9213
10209
  return;
9214
10210
  }
9215
10211
  if (subcommand === 'stage' || subcommand === 'push') {
9216
- console.log('Usage: remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--changed-only] [--data-mode test|prod] [--json|--verbose]');
10212
+ console.log('Usage: remits-cli components stage [--workset | --changed-only] [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--data-mode test|prod] [--json|--verbose]');
9217
10213
  console.log('');
9218
10214
  console.log('Stages local component files into the Redis staging cache. It never writes the database or git.');
9219
- console.log('Terminal output separates local working-tree component deltas from the full materialized staging cache count.');
10215
+ console.log('');
10216
+ console.log('Three stage modes, and the difference decides what a run in this lane resolves:');
10217
+ console.log(' (default) FULL SNAPSHOT — uploads the whole repo manifest and reconciles the lane');
10218
+ console.log(' to it. Correct as a complete snapshot and as a "what is stale?" reset;');
10219
+ console.log(' a poor progress signal, because the lane then holds every component.');
10220
+ console.log(' --workset WORKSET REPLACE — uploads only the components git reports changed and');
10221
+ console.log(' reconciles the lane to exactly them. THE NORMAL ITERATION MODE: the');
10222
+ console.log(' lane holds your work and nothing else, so the console shows what you');
10223
+ console.log(' are actually doing. An empty workset is refused, never a silent clear.');
10224
+ console.log(' --changed-only WORKSET MERGE — uploads only the changed components and leaves every');
10225
+ console.log(' other staged entry in place. Keeps its long-standing semantics, so it');
10226
+ console.log(' cannot shrink a lane inherited from an earlier full snapshot; the');
10227
+ console.log(' command warns when entries are retained that way.');
9220
10228
  console.log('');
9221
10229
  console.log(' --workspace NAME stage into an isolated lane on this branch (see `remits-cli workspace`).');
9222
- console.log(' --changed-only stage only the components this working tree edited, and do NOT reconcile');
9223
- console.log(' (entries for untouched components are left alone rather than deleted).');
10230
+ console.log(' --empty-workset clear with --workset, let an empty workset clear the lane (default: refuse).');
10231
+ console.log(' --replace-lane with --changed-only, the explicit spelling of --workset.');
10232
+ console.log('');
10233
+ console.log('Output separates three different numbers: the WORKSET git reports, what was SUBMITTED, and');
10234
+ console.log('the materialized OVERLAY the lane now holds — which is the one a run resolves.');
9224
10235
  return;
9225
10236
  }
9226
10237
  if (subcommand === 'status') {
@@ -9241,21 +10252,26 @@ function printComponentsHelp(subcommand) {
9241
10252
  return;
9242
10253
  }
9243
10254
  if (subcommand === 'commit') {
9244
- console.log('Usage: remits-cli components commit [--message "msg"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
10255
+ 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]');
9245
10256
  console.log('');
9246
10257
  console.log('Runs local git add/commit/push, then server sync. Prefer explicit git + components sync when you need inspectable phases.');
9247
10258
  console.log('components commit does not support --dry-run.');
10259
+ console.log('');
10260
+ console.log('--safe passes the sync gates through to phase 2. The honest limit: git push happens in phase 1,');
10261
+ console.log('so the gate stops the PLATFORM from writing a surprising plan — it cannot un-push the branch.');
10262
+ console.log('For a gate that runs before anything leaves your machine, push yourself and use');
10263
+ console.log('`remits-cli components sync --safe`.');
9248
10264
  return;
9249
10265
  }
9250
10266
  console.log('Usage: remits-cli components <stage|status|clear|sync|commit|promotion|branches|branch>');
9251
10267
  console.log('');
9252
10268
  console.log('All staging subcommands accept --workspace NAME to isolate a lane on the same branch,');
9253
10269
  console.log('so several agents can iterate at once. See: remits-cli workspace --help');
9254
- console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
10270
+ console.log(' remits-cli components stage [--workset|--changed-only] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
9255
10271
  console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
9256
10272
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
9257
- console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
9258
- console.log(' remits-cli components commit [--message "msg"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
10273
+ console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
10274
+ 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]');
9259
10275
  console.log('');
9260
10276
  console.log(' --force-tombstones applies only on non-trunk variant syncs, when a missing trunk');
9261
10277
  console.log(' component file is intentionally being recorded as a tombstone override.');
@@ -9405,10 +10421,10 @@ async function main() {
9405
10421
  console.log(' remits-cli tool --name <toolName> [--base-url URL] [--branch BRANCH] [--input \"{...}\"|--input-file file.json] [--data-mode test|prod] [--scope self|children|hierarchy] [--target-account-id ID] [--account-ids 1,2,3] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
9406
10422
  console.log(' remits-cli tool status --call-id <callId> [--base-url URL] [--account-id ID] [--data-mode test|prod]');
9407
10423
  console.log(' remits-cli workspace [show|use <name>|use --auto|clear] # isolate staging when several agents share a repo');
9408
- console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--changed-only] [--data-mode test|prod] [--json|--verbose]');
10424
+ console.log(' remits-cli components stage [--workset|--changed-only] [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--data-mode test|prod] [--json|--verbose]');
9409
10425
  console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--component-type TYPE --component-id ID] [--json|--verbose]');
9410
10426
  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]');
9411
- console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
10427
+ console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
9412
10428
  console.log(' remits-cli components commit [--message \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
9413
10429
  console.log(' remits-cli components promotion [<branch>] [--json] [--no-fail] # promotion readiness + ordered next steps');
9414
10430
  console.log(' remits-cli components branches [--json] # committed branch variants for this account');
@@ -9443,8 +10459,13 @@ async function main() {
9443
10459
  console.log(' - only which staged entries shadow them changes');
9444
10460
  console.log('');
9445
10461
  console.log('Typical setup, one worktree per agent, all on the same branch:');
9446
- console.log(' git worktree add ../ms-agent-a forked');
10462
+ console.log(' git fetch origin');
10463
+ console.log(' git switch forked');
10464
+ console.log(' git pull --ff-only origin forked');
10465
+ console.log(' git rev-list --left-right --count HEAD...origin/forked # must print: 0 0');
10466
+ console.log(' git worktree add --force ../ms-agent-a forked');
9447
10467
  console.log(' cd ../ms-agent-a && remits-cli workspace use --auto');
10468
+ console.log(' remits-cli components status # branch world + staging lane + other lanes');
9448
10469
  console.log(' remits-cli components stage # isolated lane; nobody else sees it');
9449
10470
  console.log(' remits-cli test run --test 42 # resolves this lane over the forked variants');
9450
10471
  console.log(' remits-cli token --path /page/x # its own browser token, in this lane');
@@ -9624,6 +10645,13 @@ async function main() {
9624
10645
  }
9625
10646
 
9626
10647
  main().catch((err) => {
10648
+ // A REFUSAL is an answer, not a fault. The platform's sentence already says exactly what is wrong and
10649
+ // what to do instead; wrapping it in "remits-cli error:" and a stack invites the caller to retry the
10650
+ // identical call, which is the one thing a refused caller must not do.
10651
+ if (err && err.refused) {
10652
+ console.error('Refused: ' + err.message);
10653
+ process.exit(1);
10654
+ }
9627
10655
  if (process.env.REMITS_CLI_DEBUG_STACK === '1' && err && err.stack) {
9628
10656
  console.error(err.stack);
9629
10657
  }