@remits/remits-cli 0.1.146 → 0.1.149
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/README.md +2 -1
- package/index.js +826 -132
- package/package.json +1 -1
- package/skills/remits-cli/SKILL.md +6 -2
- package/skills/remits-cli/references/command-reference.md +4 -1
- package/skills/remits-cli/references/support-tickets.md +47 -2
- package/skills/remits-cli/references/tool-reference.md +6 -4
package/index.js
CHANGED
|
@@ -4,18 +4,18 @@
|
|
|
4
4
|
## Table of Contents
|
|
5
5
|
|
|
6
6
|
- L22 Runtime Bootstrap And Shared State
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
7
|
+
- L124 Sessions, Account Resolution, And Production Guards
|
|
8
|
+
- L621 Local State, Workspaces, And Verification Evidence
|
|
9
|
+
- L2659 Account Repos, Guide Sync, And Platform Repo
|
|
10
|
+
- L3499 Component Discovery And HTTP Logging
|
|
11
|
+
- L4225 Skill Delivery And TOC Resolution
|
|
12
|
+
- L4540 Auth And Component Staging
|
|
13
|
+
- L5172 Component Summaries, Status, And Sync Gates
|
|
14
|
+
- L7473 Branches, Promotion, Commit, And Test Runs
|
|
15
|
+
- L9562 Tokens, Tools, Verification, And Config
|
|
16
|
+
- L10989 Service Dashboard And WebSocket Listener
|
|
17
|
+
- L13467 Agent And Ticket Workflows
|
|
18
|
+
- L17602 Help, Auto Update, And Command Dispatch
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
/*
|
|
@@ -49,6 +49,7 @@ const SESSIONS_FILE = path.join(SESSION_DIR, 'sessions.json');
|
|
|
49
49
|
const CONFIG_FILE = path.join(SESSION_DIR, 'config.json');
|
|
50
50
|
const LISTENER_PID_FILE = path.join(SESSION_DIR, 'listener.pid');
|
|
51
51
|
const ACTIVITY_LOG_FILE = path.join(SESSION_DIR, 'activity.log');
|
|
52
|
+
const GUIDES_CACHE_FILE = path.join(SESSION_DIR, 'guides-cache.json');
|
|
52
53
|
const SKILL_SOURCE_DIR = path.join(__dirname, 'skills', 'remits-cli');
|
|
53
54
|
const SKILL_SOURCE_FILE = path.join(SKILL_SOURCE_DIR, 'SKILL.md');
|
|
54
55
|
const SKILL_REFERENCES_DIRNAME = 'references';
|
|
@@ -154,6 +155,11 @@ function parseArgs(argv) {
|
|
|
154
155
|
return out;
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
// A flag's text, or '' — a bare `--role` with no value parses as `true`, which is not a value.
|
|
159
|
+
function textFlag(value) {
|
|
160
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
161
|
+
}
|
|
162
|
+
|
|
157
163
|
function flagEnabled(value) {
|
|
158
164
|
return value === true || value === 'true' || value === '1' || value === 'yes';
|
|
159
165
|
}
|
|
@@ -634,6 +640,7 @@ function localStatePaths(cwd) {
|
|
|
634
640
|
sharedDir: path.join(base, 'shared'),
|
|
635
641
|
toolsDir: path.join(base, 'shared', 'tools'),
|
|
636
642
|
workstreamsDir: path.join(base, 'shared', 'workstreams'),
|
|
643
|
+
componentStageCacheFile: path.join(base, 'shared', 'component-stage-cache.json'),
|
|
637
644
|
sessionsDir: path.join(actorDir, 'sessions'),
|
|
638
645
|
toolResponsesDir: path.join(actorDir, 'tool-responses'),
|
|
639
646
|
verificationDir: path.join(actorDir, 'verification'),
|
|
@@ -2805,6 +2812,31 @@ function writeGuidePayloadIntoRepo(repoDir, payload) {
|
|
|
2805
2812
|
// invocation that touches multiple repos only fetches guides once.
|
|
2806
2813
|
let GUIDES_PAYLOAD_CACHE = null;
|
|
2807
2814
|
|
|
2815
|
+
function readGuidesPayloadCache(cacheKey) {
|
|
2816
|
+
try {
|
|
2817
|
+
if (!fs.existsSync(GUIDES_CACHE_FILE)) return null;
|
|
2818
|
+
const cached = JSON.parse(fs.readFileSync(GUIDES_CACHE_FILE, 'utf8'));
|
|
2819
|
+
if (!cached || cached.key !== cacheKey || !cached.payload || !cached.guideHash) return null;
|
|
2820
|
+
return cached;
|
|
2821
|
+
} catch (_) {
|
|
2822
|
+
return null;
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
function writeGuidesPayloadCache(cacheKey, payload) {
|
|
2827
|
+
if (!payload || !payload.guideHash) return;
|
|
2828
|
+
try {
|
|
2829
|
+
atomicWriteFile(GUIDES_CACHE_FILE, JSON.stringify({
|
|
2830
|
+
key: cacheKey,
|
|
2831
|
+
guideHash: payload.guideHash,
|
|
2832
|
+
payload,
|
|
2833
|
+
updatedAt: new Date().toISOString()
|
|
2834
|
+
}, null, 2));
|
|
2835
|
+
} catch (_) {
|
|
2836
|
+
// Best-effort: the in-process cache still covers this invocation.
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2808
2840
|
async function fetchGuidesPayload(cwd, ctx) {
|
|
2809
2841
|
const baseUrl = normalizeBaseUrl((ctx && ctx.baseUrl) || DEFAULT_BASE_URL);
|
|
2810
2842
|
const token = ctx && ctx.session && ctx.session.token;
|
|
@@ -2815,21 +2847,28 @@ async function fetchGuidesPayload(cwd, ctx) {
|
|
|
2815
2847
|
if (GUIDES_PAYLOAD_CACHE && GUIDES_PAYLOAD_CACHE.key === cacheKey) {
|
|
2816
2848
|
return GUIDES_PAYLOAD_CACHE.payload;
|
|
2817
2849
|
}
|
|
2850
|
+
const diskCache = readGuidesPayloadCache(cacheKey);
|
|
2818
2851
|
const api = buildAxios(baseUrl, token);
|
|
2819
2852
|
let data = null;
|
|
2820
2853
|
try {
|
|
2821
2854
|
data = await loggedPost(api, cwd, '/cli/guides', {
|
|
2822
2855
|
token,
|
|
2823
|
-
accountId: (ctx && ctx.accountId) || undefined
|
|
2856
|
+
accountId: (ctx && ctx.accountId) || undefined,
|
|
2857
|
+
guideHash: diskCache && diskCache.guideHash ? diskCache.guideHash : undefined
|
|
2824
2858
|
}).then((r) => r.data);
|
|
2825
2859
|
} catch (_) {
|
|
2826
2860
|
// Best-effort: never fail a command because guides could not be fetched.
|
|
2827
2861
|
return null;
|
|
2828
2862
|
}
|
|
2863
|
+
if (data && data.notModified && diskCache && diskCache.payload) {
|
|
2864
|
+
GUIDES_PAYLOAD_CACHE = { key: cacheKey, payload: diskCache.payload };
|
|
2865
|
+
return diskCache.payload;
|
|
2866
|
+
}
|
|
2829
2867
|
if (!data || !data.success) {
|
|
2830
2868
|
return null;
|
|
2831
2869
|
}
|
|
2832
2870
|
GUIDES_PAYLOAD_CACHE = { key: cacheKey, payload: data };
|
|
2871
|
+
writeGuidesPayloadCache(cacheKey, data);
|
|
2833
2872
|
return data;
|
|
2834
2873
|
}
|
|
2835
2874
|
|
|
@@ -3672,6 +3711,79 @@ function collectComponents(cwd) {
|
|
|
3672
3711
|
return canonicalized.components;
|
|
3673
3712
|
}
|
|
3674
3713
|
|
|
3714
|
+
const STAGE_CONTENT_FIELDS = ['source', 'prompt', 'html', 'javascript', 'schema', 'inputSchema', 'previewData', 'messages'];
|
|
3715
|
+
|
|
3716
|
+
function componentStageCacheKey(baseUrl, accountId, branchName, workspace, dataMode) {
|
|
3717
|
+
return [normalizeBaseUrl(baseUrl || DEFAULT_BASE_URL), accountId || '', branchName || '', workspace || '', normalizeDataMode(dataMode)].join('|');
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
function readComponentStageCache(cwd) {
|
|
3721
|
+
const file = localStatePaths(cwd).componentStageCacheFile;
|
|
3722
|
+
try {
|
|
3723
|
+
if (!fs.existsSync(file)) return {};
|
|
3724
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
3725
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
3726
|
+
} catch (_) {
|
|
3727
|
+
return {};
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
|
|
3731
|
+
function writeComponentStageCache(cwd, cache) {
|
|
3732
|
+
try {
|
|
3733
|
+
atomicWriteFile(localStatePaths(cwd).componentStageCacheFile, JSON.stringify(cache || {}, null, 2));
|
|
3734
|
+
} catch (_) {
|
|
3735
|
+
// Best-effort. A missed cache only means the next stage sends full content.
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3739
|
+
function slimComponentsForStage(cwd, components, world) {
|
|
3740
|
+
const cache = readComponentStageCache(cwd);
|
|
3741
|
+
const worldKey = componentStageCacheKey(world.baseUrl, world.accountId, world.branchName, world.workspace, world.dataMode);
|
|
3742
|
+
const worldCache = cache[worldKey] && typeof cache[worldKey] === 'object' ? cache[worldKey] : {};
|
|
3743
|
+
let omittedComponents = 0;
|
|
3744
|
+
let omittedFields = 0;
|
|
3745
|
+
let omittedBytes = 0;
|
|
3746
|
+
const slim = (components || []).map((component) => {
|
|
3747
|
+
const identity = stageIdentityKey(component);
|
|
3748
|
+
const cached = worldCache[identity];
|
|
3749
|
+
if (!cached || cached.hash !== component.hash) {
|
|
3750
|
+
return component;
|
|
3751
|
+
}
|
|
3752
|
+
const omitted = [];
|
|
3753
|
+
const next = Object.assign({}, component);
|
|
3754
|
+
for (const field of STAGE_CONTENT_FIELDS) {
|
|
3755
|
+
if (typeof next[field] === 'string') {
|
|
3756
|
+
omitted.push(field);
|
|
3757
|
+
omittedBytes += Buffer.byteLength(next[field], 'utf8');
|
|
3758
|
+
delete next[field];
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3761
|
+
if (omitted.length) {
|
|
3762
|
+
next.omittedContentFields = omitted;
|
|
3763
|
+
omittedComponents += 1;
|
|
3764
|
+
omittedFields += omitted.length;
|
|
3765
|
+
return next;
|
|
3766
|
+
}
|
|
3767
|
+
return component;
|
|
3768
|
+
});
|
|
3769
|
+
return { components: slim, stats: { omittedComponents, omittedFields, omittedBytes } };
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3772
|
+
function rememberSuccessfulComponentStage(cwd, components, world) {
|
|
3773
|
+
const cache = readComponentStageCache(cwd);
|
|
3774
|
+
const worldKey = componentStageCacheKey(world.baseUrl, world.accountId, world.branchName, world.workspace, world.dataMode);
|
|
3775
|
+
const worldCache = {};
|
|
3776
|
+
for (const component of components || []) {
|
|
3777
|
+
if (!component || !component.hash) continue;
|
|
3778
|
+
worldCache[stageIdentityKey(component)] = {
|
|
3779
|
+
hash: component.hash,
|
|
3780
|
+
updatedAt: new Date().toISOString()
|
|
3781
|
+
};
|
|
3782
|
+
}
|
|
3783
|
+
cache[worldKey] = worldCache;
|
|
3784
|
+
writeComponentStageCache(cwd, cache);
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3675
3787
|
function componentPathInfo(cwd, filePath) {
|
|
3676
3788
|
const mapping = {
|
|
3677
3789
|
schemas: 'schema',
|
|
@@ -3868,6 +3980,12 @@ function buildAxios(baseUrl, token, timeoutMs = 60000) {
|
|
|
3868
3980
|
// diagnostics it is missing exist, and every output improvement lands invisibly.
|
|
3869
3981
|
const cliVersion = readCliVersion();
|
|
3870
3982
|
if (cliVersion) headers['X-Remits-Cli-Version'] = cliVersion;
|
|
3983
|
+
// A worker's run identity rides on every call, so a verb whose payload does not repeat it (stage, commit,
|
|
3984
|
+
// a tool) is still attributed to the run. The ROLE is deliberately not sent: the platform reads it from
|
|
3985
|
+
// the session's registration, where the user's permission to declare it was checked.
|
|
3986
|
+
if (process.env.REMITS_AGENT_ID) headers['X-Remits-Agent-Id'] = process.env.REMITS_AGENT_ID;
|
|
3987
|
+
if (process.env.REMITS_WORKER_RUN_ID) headers['X-Remits-Run-Id'] = process.env.REMITS_WORKER_RUN_ID;
|
|
3988
|
+
if (process.env.REMITS_SUPPORT_TICKET_ID) headers['X-Remits-Ticket-Id'] = process.env.REMITS_SUPPORT_TICKET_ID;
|
|
3871
3989
|
return axios.create({ baseURL: baseUrl, timeout: parsePositiveInt(timeoutMs, 60000), headers });
|
|
3872
3990
|
}
|
|
3873
3991
|
|
|
@@ -4274,16 +4392,21 @@ function resolveSkillTargets(target) {
|
|
|
4274
4392
|
gemini: {
|
|
4275
4393
|
skillPath: path.join(home, '.gemini', 'skills', 'remits-cli.md'),
|
|
4276
4394
|
referencesDir: path.join(home, '.gemini', 'skills', 'remits-cli', SKILL_REFERENCES_DIRNAME)
|
|
4395
|
+
},
|
|
4396
|
+
antigravity: {
|
|
4397
|
+
skillPath: path.join(home, '.gemini', 'config', 'skills', 'remits-cli', 'SKILL.md'),
|
|
4398
|
+
referencesDir: path.join(home, '.gemini', 'config', 'skills', 'remits-cli', SKILL_REFERENCES_DIRNAME)
|
|
4277
4399
|
}
|
|
4278
4400
|
};
|
|
4279
4401
|
|
|
4280
4402
|
if (value === 'all') {
|
|
4281
4403
|
return Object.entries(targets);
|
|
4282
4404
|
}
|
|
4283
|
-
|
|
4284
|
-
|
|
4405
|
+
const resolved = value === 'agy' ? 'antigravity' : value;
|
|
4406
|
+
if (!targets[resolved]) {
|
|
4407
|
+
throw new Error('Unknown --target value: ' + target + ' (expected codex|claude|gemini|antigravity|all)');
|
|
4285
4408
|
}
|
|
4286
|
-
return [[
|
|
4409
|
+
return [[resolved, targets[resolved]]];
|
|
4287
4410
|
}
|
|
4288
4411
|
|
|
4289
4412
|
function listSkillReferenceSources() {
|
|
@@ -4737,6 +4860,7 @@ async function pushComponentsCommand(flags) {
|
|
|
4737
4860
|
packetType: 'stage',
|
|
4738
4861
|
world: { repoAccountId: accountId, accountId, executionAccountId: accountId, dataMode, branchName, workspace }
|
|
4739
4862
|
}, { command: 'components stage' });
|
|
4863
|
+
const slimStage = slimComponentsForStage(cwd, components, { baseUrl, accountId, branchName, workspace, dataMode });
|
|
4740
4864
|
const stagePayload = {
|
|
4741
4865
|
token: session.token,
|
|
4742
4866
|
accountId,
|
|
@@ -4768,13 +4892,20 @@ async function pushComponentsCommand(flags) {
|
|
|
4768
4892
|
baseSha: safeGitValue(cwd, 'git rev-parse HEAD') || undefined,
|
|
4769
4893
|
// Kept for a platform that predates stageMode. Same meaning it always had.
|
|
4770
4894
|
replace: !changedOnly,
|
|
4771
|
-
components
|
|
4895
|
+
components: slimStage.components,
|
|
4896
|
+
omittedContentStats: slimStage.stats
|
|
4772
4897
|
};
|
|
4773
4898
|
|
|
4774
4899
|
let response;
|
|
4775
4900
|
try {
|
|
4776
4901
|
response = await stageOrRefuse(api, cwd, stagePayload);
|
|
4777
4902
|
} catch (err) {
|
|
4903
|
+
if (err && err.responseBody && err.responseBody.code === 'missing_omitted_content' &&
|
|
4904
|
+
stagePayload.components !== components) {
|
|
4905
|
+
stagePayload.components = components;
|
|
4906
|
+
stagePayload.omittedContentStats = Object.assign({}, slimStage.stats, { retriedFull: true });
|
|
4907
|
+
response = await stageOrRefuse(api, cwd, stagePayload);
|
|
4908
|
+
} else {
|
|
4778
4909
|
// A REFUSED stage is evidence too. Recording it only on the success path is how a lane ends up with
|
|
4779
4910
|
// an envelope that says nothing happened between two packets, when in fact a stage was attempted and
|
|
4780
4911
|
// the platform rejected it. The packet is best-effort: a failing stage must surface the STAGE error,
|
|
@@ -4803,6 +4934,7 @@ async function pushComponentsCommand(flags) {
|
|
|
4803
4934
|
console.error('Could not record the refused-stage verification packet: ' + packetError.message);
|
|
4804
4935
|
}
|
|
4805
4936
|
throw err;
|
|
4937
|
+
}
|
|
4806
4938
|
}
|
|
4807
4939
|
response.changedFromWorkingTree = changedFromWorkingTree || [];
|
|
4808
4940
|
response.changedFromWorkingTreeAvailable = changedFromWorkingTree !== null;
|
|
@@ -4810,12 +4942,14 @@ async function pushComponentsCommand(flags) {
|
|
|
4810
4942
|
response.unrepresentableChanges = unstageable;
|
|
4811
4943
|
response.componentDiscoveryWarnings = componentDiscoveryWarnings;
|
|
4812
4944
|
response.localState = localStateSummary(cwd, flags);
|
|
4945
|
+
response.omittedContentStats = stagePayload.omittedContentStats || slimStage.stats;
|
|
4813
4946
|
if (response.success === false) {
|
|
4814
4947
|
// A 200 that says `success:false`. The 422 path throws out of stageOrRefuse above and never lands
|
|
4815
4948
|
// here, so this stays as the belt-and-braces case — print whatever detail came with it first.
|
|
4816
4949
|
printCompileValidation(response.compileValidation, { stderr: true });
|
|
4817
4950
|
throw new Error(response.message || 'Server stage failed');
|
|
4818
4951
|
}
|
|
4952
|
+
rememberSuccessfulComponentStage(cwd, components, { baseUrl, accountId, branchName, workspace, dataMode });
|
|
4819
4953
|
|
|
4820
4954
|
await appendVerificationPacket(api, cwd, session, accountId, verification.evidenceFlags, {
|
|
4821
4955
|
type: 'stage',
|
|
@@ -4993,6 +5127,12 @@ async function stageOrRefuse(api, cwd, payload) {
|
|
|
4993
5127
|
} catch (err) {
|
|
4994
5128
|
const body = err && err.response && err.response.data;
|
|
4995
5129
|
const status = err && err.response && err.response.status;
|
|
5130
|
+
if (status === 409 && body && body.code === 'missing_omitted_content') {
|
|
5131
|
+
const retryable = new Error(body.message || 'Server needs full component bodies for this staging lane.');
|
|
5132
|
+
retryable.responseBody = body;
|
|
5133
|
+
retryable.status = status;
|
|
5134
|
+
throw retryable;
|
|
5135
|
+
}
|
|
4996
5136
|
if (status === 409 && body && body.message) {
|
|
4997
5137
|
if (body.editLease) {
|
|
4998
5138
|
console.error('Edit lease: ' + describeRunLocation(body.editLease));
|
|
@@ -6239,7 +6379,8 @@ async function syncComponentsCommand(rawFlags) {
|
|
|
6239
6379
|
const dataMode = resolveDataMode(flags, session);
|
|
6240
6380
|
const forceTombstones = flagEnabled(flags['force-tombstones']) || flagEnabled(flags.forceTombstones);
|
|
6241
6381
|
const dryRun = flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun);
|
|
6242
|
-
const
|
|
6382
|
+
const timeoutMs = parsePositiveInt(flags['timeout-ms'] || flags.timeoutMs, 60000);
|
|
6383
|
+
const api = buildAxios(baseUrl, session.token, timeoutMs);
|
|
6243
6384
|
// Aligned for the same reason a stage is: the server's plan names a `new_` component by its sidecar
|
|
6244
6385
|
// `name:`, and the changed set would otherwise name it by its filename and the gate would refuse it.
|
|
6245
6386
|
const changedFromWorkingTree = alignChangedSetWithComponents(changedComponentsFromWorkingTree(cwd), collectComponentsForIdentity(cwd));
|
|
@@ -11669,6 +11810,54 @@ function compactSupportTicketRow(row) {
|
|
|
11669
11810
|
};
|
|
11670
11811
|
}
|
|
11671
11812
|
|
|
11813
|
+
function supportTicketQueueScopeSet(queue) {
|
|
11814
|
+
return new Set((queue && queue.searchedAccounts || [])
|
|
11815
|
+
.map((entry) => String(entry.accountId || entry.accountScope || ''))
|
|
11816
|
+
.filter(Boolean));
|
|
11817
|
+
}
|
|
11818
|
+
|
|
11819
|
+
function independentSupportTicketQueues(queues) {
|
|
11820
|
+
const scoped = (queues || []).map((queue) => supportTicketQueueScopeSet(queue));
|
|
11821
|
+
return (queues || []).filter((queue, i) => {
|
|
11822
|
+
if (!scoped[i].size) {
|
|
11823
|
+
return true; // nothing reported: cannot prove overlap, so keep it and report honestly below
|
|
11824
|
+
}
|
|
11825
|
+
return !(queues || []).some((other, j) => {
|
|
11826
|
+
if (i === j || !scoped[j].size) return false;
|
|
11827
|
+
const contained = [...scoped[i]].every((id) => scoped[j].has(id));
|
|
11828
|
+
if (!contained) return false;
|
|
11829
|
+
// Identical scopes: keep the earlier one, drop the later, or both would drop each other.
|
|
11830
|
+
const identical = scoped[i].size === scoped[j].size;
|
|
11831
|
+
return identical ? j < i : true;
|
|
11832
|
+
});
|
|
11833
|
+
});
|
|
11834
|
+
}
|
|
11835
|
+
|
|
11836
|
+
function supportTicketCompartmentGroups(queues) {
|
|
11837
|
+
const byCompartment = {};
|
|
11838
|
+
for (const queue of queues || []) {
|
|
11839
|
+
const key = queue.compartment || (queue.baseUrl + ' · ' + (queue.dataMode || 'prod') + ' lane');
|
|
11840
|
+
if (!byCompartment[key]) {
|
|
11841
|
+
byCompartment[key] = { compartment: key, baseUrl: queue.baseUrl, dataMode: queue.dataMode, queues: [] };
|
|
11842
|
+
}
|
|
11843
|
+
byCompartment[key].queues.push(queue);
|
|
11844
|
+
}
|
|
11845
|
+
|
|
11846
|
+
return Object.values(byCompartment).map((group) => {
|
|
11847
|
+
const independent = independentSupportTicketQueues(group.queues);
|
|
11848
|
+
const ticketsById = {};
|
|
11849
|
+
for (const queue of independent) {
|
|
11850
|
+
for (const ticket of queue.tickets || []) {
|
|
11851
|
+
const id = String(ticket.ticketId || ticket.id || '');
|
|
11852
|
+
if (!id || ticketsById[id]) continue;
|
|
11853
|
+
ticketsById[id] = { ...ticket, queueAccountId: queue.accountId, baseUrl: queue.baseUrl,
|
|
11854
|
+
dataMode: queue.dataMode, compartment: group.compartment };
|
|
11855
|
+
}
|
|
11856
|
+
}
|
|
11857
|
+
return { ...group, independent, tickets: Object.values(ticketsById) };
|
|
11858
|
+
});
|
|
11859
|
+
}
|
|
11860
|
+
|
|
11672
11861
|
/**
|
|
11673
11862
|
* The support-ticket queue for one authenticated session.
|
|
11674
11863
|
*
|
|
@@ -11853,44 +12042,9 @@ async function collectAgentSnapshot() {
|
|
|
11853
12042
|
* </ul>
|
|
11854
12043
|
*/
|
|
11855
12044
|
function buildTicketCompartments(queues) {
|
|
11856
|
-
|
|
11857
|
-
|
|
11858
|
-
const
|
|
11859
|
-
if (!byCompartment[key]) {
|
|
11860
|
-
byCompartment[key] = { compartment: key, baseUrl: queue.baseUrl, dataMode: queue.dataMode, queues: [] };
|
|
11861
|
-
}
|
|
11862
|
-
byCompartment[key].queues.push(queue);
|
|
11863
|
-
}
|
|
11864
|
-
|
|
11865
|
-
return Object.values(byCompartment).map((group) => {
|
|
11866
|
-
// Drop any queue whose scanned accounts are wholly contained in another's: its tickets are already
|
|
11867
|
-
// in that one, so keeping it would count them twice. Equal sets keep the first seen.
|
|
11868
|
-
const scanned = group.queues.map((queue) => new Set(
|
|
11869
|
-
(queue.searchedAccounts || []).map((entry) => String(entry.accountId || entry.accountScope || ''))));
|
|
11870
|
-
const independent = group.queues.filter((queue, i) => {
|
|
11871
|
-
if (!scanned[i].size) {
|
|
11872
|
-
return true; // nothing reported: cannot prove overlap, so keep it and report honestly below
|
|
11873
|
-
}
|
|
11874
|
-
return !group.queues.some((other, j) => {
|
|
11875
|
-
if (i === j || !scanned[j].size) return false;
|
|
11876
|
-
const contained = [...scanned[i]].every((id) => scanned[j].has(id));
|
|
11877
|
-
if (!contained) return false;
|
|
11878
|
-
// Identical scopes: keep the earlier one, drop the later, or both would drop each other.
|
|
11879
|
-
const identical = scanned[i].size === scanned[j].size;
|
|
11880
|
-
return identical ? j < i : true;
|
|
11881
|
-
});
|
|
11882
|
-
});
|
|
11883
|
-
|
|
11884
|
-
const ticketsById = {};
|
|
11885
|
-
for (const queue of independent) {
|
|
11886
|
-
for (const ticket of queue.tickets || []) {
|
|
11887
|
-
const id = String(ticket.ticketId || '');
|
|
11888
|
-
if (!id || ticketsById[id]) continue;
|
|
11889
|
-
ticketsById[id] = { ...ticket, queueAccountId: queue.accountId, baseUrl: queue.baseUrl,
|
|
11890
|
-
dataMode: queue.dataMode, compartment: group.compartment };
|
|
11891
|
-
}
|
|
11892
|
-
}
|
|
11893
|
-
const tickets = Object.values(ticketsById);
|
|
12045
|
+
return supportTicketCompartmentGroups(queues).map((group) => {
|
|
12046
|
+
const independent = group.independent;
|
|
12047
|
+
const tickets = group.tickets;
|
|
11894
12048
|
|
|
11895
12049
|
const open = independent.reduce((sum, queue) => sum + Number(queue.totalCount || 0), 0);
|
|
11896
12050
|
const routed = independent.reduce((sum, queue) => sum + Number((queue.counts || {}).routed || 0), 0);
|
|
@@ -12855,7 +13009,7 @@ async function handleCliCloneRequest(client, baseUrl, topic, accountIds, userIds
|
|
|
12855
13009
|
console.warn('[cli_clone_request] Guide sync failed:', gErr.message);
|
|
12856
13010
|
}
|
|
12857
13011
|
|
|
12858
|
-
// 4. Install / refresh agent skills for Claude, Codex, Gemini
|
|
13012
|
+
// 4. Install / refresh agent skills for Claude, Codex, Gemini, Antigravity
|
|
12859
13013
|
try {
|
|
12860
13014
|
syncInstalledSkills('all');
|
|
12861
13015
|
} catch (_) {}
|
|
@@ -14056,6 +14210,8 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
14056
14210
|
'the pid whose exit should end this agent session.');
|
|
14057
14211
|
}
|
|
14058
14212
|
const explicitId = (flags['agent-id'] || flags.agentId || process.env.REMITS_AGENT_ID || '').trim();
|
|
14213
|
+
const explicitRole = textFlag(flags.role) || textFlag(options.serve && options.serve.role) || textFlag(process.env.REMITS_AGENT_ROLE);
|
|
14214
|
+
const explicitPersona = textFlag(flags.persona) || textFlag(options.serve && options.serve.persona) || textFlag(process.env.REMITS_AGENT_PERSONA);
|
|
14059
14215
|
// Adopt a previous registration only for THIS terminal. Matching on (cwd, anchorPid) alone was enough
|
|
14060
14216
|
// to let an unrelated record be adopted whenever two registrations shared an anchor.
|
|
14061
14217
|
const existingId = explicitId ||
|
|
@@ -14066,6 +14222,8 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
14066
14222
|
const api = buildAxios(baseUrl, session.token, 15000);
|
|
14067
14223
|
const payload = {
|
|
14068
14224
|
agentId: existingId || undefined,
|
|
14225
|
+
role: explicitRole || explicitPersona || undefined,
|
|
14226
|
+
persona: explicitPersona || undefined,
|
|
14069
14227
|
label: flags.label || agentLabelDefault(anchor, cwd),
|
|
14070
14228
|
agentType: flags.agent || getPreferredAgent(),
|
|
14071
14229
|
cliVersion: readCliVersion(),
|
|
@@ -14110,6 +14268,8 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
14110
14268
|
cwd,
|
|
14111
14269
|
baseUrl,
|
|
14112
14270
|
dataMode,
|
|
14271
|
+
role: response.role || payload.role || null,
|
|
14272
|
+
persona: response.persona || payload.persona || null,
|
|
14113
14273
|
// The accounts the platform ACCEPTED, so `agent list` can scope itself to the work this session
|
|
14114
14274
|
// can actually be routed rather than to whichever session happened to be most recent.
|
|
14115
14275
|
accountIds: response.accountIds || [],
|
|
@@ -14133,6 +14293,8 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
14133
14293
|
entry.mode = options.serve.mode;
|
|
14134
14294
|
entry.serves = options.serve.serves || [];
|
|
14135
14295
|
entry.maxConcurrent = options.serve.maxConcurrent;
|
|
14296
|
+
entry.role = options.serve.role || entry.role;
|
|
14297
|
+
entry.persona = options.serve.persona || entry.persona;
|
|
14136
14298
|
}
|
|
14137
14299
|
rememberLocalAgent(entry);
|
|
14138
14300
|
}
|
|
@@ -14145,6 +14307,9 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
14145
14307
|
const accepted = response.accountIds || [];
|
|
14146
14308
|
console.log('Registered as a support agent: ' + response.agentId);
|
|
14147
14309
|
console.log(' label ' + payload.label);
|
|
14310
|
+
if (response.role || payload.role) {
|
|
14311
|
+
console.log(' role ' + (response.role || payload.role) + (response.persona ? ' (persona: ' + response.persona + ')' : ''));
|
|
14312
|
+
}
|
|
14148
14313
|
console.log(' platform ' + baseUrl);
|
|
14149
14314
|
console.log(' data lane ' + dataMode + (dataMode === 'prod' ? ' (live support tickets)' : ' (fixtures only)'));
|
|
14150
14315
|
console.log(' accounts ' + accepted.length + ' account(s) this session can be routed work for');
|
|
@@ -14166,6 +14331,9 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
14166
14331
|
console.log('AUTONOMOUS. A supervisor is now running in the background for this session.');
|
|
14167
14332
|
console.log(' worker ' + serve.workerAgent + ' (' + serve.mode + ' mode)');
|
|
14168
14333
|
console.log(' ' + serve.workerAgentWhy);
|
|
14334
|
+
if (serve.role) {
|
|
14335
|
+
console.log(' role ' + serve.role + (serve.persona && serve.persona !== serve.role ? ' (persona: ' + serve.persona + ')' : ''));
|
|
14336
|
+
}
|
|
14169
14337
|
console.log(' capacity ' + serve.maxConcurrent + ' ticket(s) at a time');
|
|
14170
14338
|
console.log(' serves ' + ((serve.serves && serve.serves.length)
|
|
14171
14339
|
? serve.serves.join(', ') + ' (only these workstreams are routed here, and only these are swept)'
|
|
@@ -14214,6 +14382,12 @@ async function agentServeCommand(flags) {
|
|
|
14214
14382
|
const mode = String(flags.mode || (flagEnabled(flags['investigate-only']) ? 'investigate' : 'edit'))
|
|
14215
14383
|
.toLowerCase() === 'investigate' ? 'investigate' : 'edit';
|
|
14216
14384
|
const serves = parseServedWorkstreams(flags.serves);
|
|
14385
|
+
const role = textFlag(flags.role) || textFlag(flags.persona) || textFlag(process.env.REMITS_AGENT_ROLE);
|
|
14386
|
+
const persona = textFlag(flags.persona) || textFlag(process.env.REMITS_AGENT_PERSONA);
|
|
14387
|
+
if (role || persona) {
|
|
14388
|
+
console.log('');
|
|
14389
|
+
console.log('Serving as role: ' + (role || persona) + (persona && persona !== role ? ' (persona: ' + persona + ')' : ''));
|
|
14390
|
+
}
|
|
14217
14391
|
if (serves.length) {
|
|
14218
14392
|
console.log('');
|
|
14219
14393
|
console.log('Serving workstreams: ' + serves.join(', '));
|
|
@@ -14259,6 +14433,8 @@ async function agentServeCommand(flags) {
|
|
|
14259
14433
|
workerAgentWhy: choice.why,
|
|
14260
14434
|
mode,
|
|
14261
14435
|
serves,
|
|
14436
|
+
role: role || persona || null,
|
|
14437
|
+
persona: persona || null,
|
|
14262
14438
|
maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1))
|
|
14263
14439
|
}
|
|
14264
14440
|
});
|
|
@@ -14368,6 +14544,12 @@ function startAgentHeartbeatDaemon(entry, serve = null) {
|
|
|
14368
14544
|
'--base-url', entry.baseUrl,
|
|
14369
14545
|
'--cwd', entry.cwd
|
|
14370
14546
|
];
|
|
14547
|
+
if (entry.role) {
|
|
14548
|
+
args.push('--role', entry.role);
|
|
14549
|
+
}
|
|
14550
|
+
if (entry.persona) {
|
|
14551
|
+
args.push('--persona', entry.persona);
|
|
14552
|
+
}
|
|
14371
14553
|
if (serve) {
|
|
14372
14554
|
args.push(
|
|
14373
14555
|
'--serve', 'true',
|
|
@@ -14381,6 +14563,12 @@ function startAgentHeartbeatDaemon(entry, serve = null) {
|
|
|
14381
14563
|
if (serve.serves && serve.serves.length) {
|
|
14382
14564
|
args.push('--serves', serve.serves.join(','));
|
|
14383
14565
|
}
|
|
14566
|
+
if (serve.role && !entry.role) {
|
|
14567
|
+
args.push('--role', serve.role);
|
|
14568
|
+
}
|
|
14569
|
+
if (serve.persona && !entry.persona) {
|
|
14570
|
+
args.push('--persona', serve.persona);
|
|
14571
|
+
}
|
|
14384
14572
|
}
|
|
14385
14573
|
const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', env: { ...process.env } });
|
|
14386
14574
|
child.unref();
|
|
@@ -14389,6 +14577,8 @@ function startAgentHeartbeatDaemon(entry, serve = null) {
|
|
|
14389
14577
|
anchorPid: entry.anchorPid,
|
|
14390
14578
|
heartbeatPid: child.pid,
|
|
14391
14579
|
serving: !!serve,
|
|
14580
|
+
role: entry.role || (serve ? serve.role : null),
|
|
14581
|
+
persona: entry.persona || (serve ? serve.persona : null),
|
|
14392
14582
|
workerAgent: serve ? serve.workerAgent : null,
|
|
14393
14583
|
mode: serve ? serve.mode : null,
|
|
14394
14584
|
maxConcurrent: serve ? serve.maxConcurrent : null
|
|
@@ -14869,6 +15059,8 @@ async function agentHeartbeatDaemonCommand(flags) {
|
|
|
14869
15059
|
process.env.REMITS_AGENT_ANCHOR_PID = String(anchorPid);
|
|
14870
15060
|
|
|
14871
15061
|
const serving = flagEnabled(flags.serve);
|
|
15062
|
+
const role = textFlag(flags.role) || textFlag(process.env.REMITS_AGENT_ROLE);
|
|
15063
|
+
const persona = textFlag(flags.persona) || textFlag(process.env.REMITS_AGENT_PERSONA);
|
|
14872
15064
|
const ctx = {
|
|
14873
15065
|
agentId,
|
|
14874
15066
|
cwd,
|
|
@@ -14878,6 +15070,8 @@ async function agentHeartbeatDaemonCommand(flags) {
|
|
|
14878
15070
|
mode: String(flags.mode || 'edit').toLowerCase() === 'investigate' ? 'investigate' : 'edit',
|
|
14879
15071
|
serves: parseServedWorkstreams(flags.serves),
|
|
14880
15072
|
maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1)),
|
|
15073
|
+
role: role || persona || null,
|
|
15074
|
+
persona: persona || null,
|
|
14881
15075
|
runId: 'run-' + Date.now().toString(36)
|
|
14882
15076
|
};
|
|
14883
15077
|
const inFlight = new Map();
|
|
@@ -15035,6 +15229,8 @@ async function agentHeartbeatDaemonCommand(flags) {
|
|
|
15035
15229
|
'agent-id': agentId,
|
|
15036
15230
|
'base-url': context.baseUrl,
|
|
15037
15231
|
'max-concurrent': String(ctx.maxConcurrent),
|
|
15232
|
+
role: ctx.role || undefined,
|
|
15233
|
+
persona: ctx.persona || undefined,
|
|
15038
15234
|
json: 'true'
|
|
15039
15235
|
},
|
|
15040
15236
|
// Re-register with THIS supervisor's declared bound and queues. The platform treats an absent
|
|
@@ -15042,14 +15238,21 @@ async function agentHeartbeatDaemonCommand(flags) {
|
|
|
15042
15238
|
// here: this path runs precisely because the record EXPIRED, so there is nothing left to be
|
|
15043
15239
|
// unchanged from, and a specialised machine would come back as a generalist that can edit.
|
|
15044
15240
|
{ spawnHeartbeat: false,
|
|
15045
|
-
serve: { mode: ctx.mode, serves: ctx.serves, maxConcurrent: ctx.maxConcurrent } }
|
|
15241
|
+
serve: { mode: ctx.mode, serves: ctx.serves, maxConcurrent: ctx.maxConcurrent, role: ctx.role, persona: ctx.persona } }
|
|
15046
15242
|
).catch((err) => {
|
|
15047
15243
|
appendGlobalActivityLog('agent', 'heartbeat.reregister_failed',
|
|
15048
15244
|
{ agentId, error: describeError(err) }, 'error');
|
|
15049
15245
|
});
|
|
15050
15246
|
}
|
|
15051
15247
|
} catch (err) {
|
|
15052
|
-
|
|
15248
|
+
// A refusal is the platform saying this user may no longer run this session as declared (a role,
|
|
15249
|
+
// workstream or autonomy was revoked). Recorded as itself so it is not read as a network blip.
|
|
15250
|
+
const body = err && err.response && err.response.data;
|
|
15251
|
+
if (err && err.response && err.response.status === 403 && body && body.refused) {
|
|
15252
|
+
appendGlobalActivityLog('agent', 'serve.refused', { agentId, message: body.message }, 'error');
|
|
15253
|
+
} else {
|
|
15254
|
+
appendGlobalActivityLog('agent', 'heartbeat.failed', { agentId, error: describeError(err) }, 'error');
|
|
15255
|
+
}
|
|
15053
15256
|
}
|
|
15054
15257
|
|
|
15055
15258
|
const interval = !serving
|
|
@@ -15077,6 +15280,9 @@ async function agentStatusCommand(flags) {
|
|
|
15077
15280
|
if (flags.ticket || flags['ticket-id']) update.ticketId = String(flags.ticket || flags['ticket-id']);
|
|
15078
15281
|
if (flags.step) update.step = String(flags.step);
|
|
15079
15282
|
if (flags.detail) update.detail = String(flags.detail);
|
|
15283
|
+
if (flags.role || flags.persona) {
|
|
15284
|
+
throw new Error('A session\'s role is fixed when it registers. Re-register with `remits-cli agent register --role ROLE`.');
|
|
15285
|
+
}
|
|
15080
15286
|
|
|
15081
15287
|
// Reporting a ticket without a state is unambiguous: you are working it.
|
|
15082
15288
|
if (update.ticketId && !update.state) update.state = 'working';
|
|
@@ -15098,6 +15304,7 @@ async function agentStatusCommand(flags) {
|
|
|
15098
15304
|
// `||` binds looser than `+`, so writing this as one expression prints only the state and silently
|
|
15099
15305
|
// drops the ticket and activity — the two things the caller just set.
|
|
15100
15306
|
const parts = [agent.state || 'idle'];
|
|
15307
|
+
if (agent.role) parts.push('role: ' + agent.role);
|
|
15101
15308
|
if (agent.ticketId) parts.push('ticket ' + agent.ticketId);
|
|
15102
15309
|
if (agent.activity) parts.push(agent.activity);
|
|
15103
15310
|
console.log(parts.join(' '));
|
|
@@ -15428,6 +15635,336 @@ async function ticketCommand(flags, subcommand) {
|
|
|
15428
15635
|
}
|
|
15429
15636
|
}
|
|
15430
15637
|
|
|
15638
|
+
function csvFlag(value) {
|
|
15639
|
+
if (!value) return undefined;
|
|
15640
|
+
const parts = Array.isArray(value) ? value : [value];
|
|
15641
|
+
const values = parts.flatMap((part) => String(part).split(',').map((v) => v.trim()).filter(Boolean));
|
|
15642
|
+
return values.length ? values : undefined;
|
|
15643
|
+
}
|
|
15644
|
+
|
|
15645
|
+
function buildTicketQueueRequestPayload(flags, accountId, dataMode, defaultLimit = 25) {
|
|
15646
|
+
return {
|
|
15647
|
+
accountId,
|
|
15648
|
+
dataMode,
|
|
15649
|
+
statuses: csvFlag(flags.status || flags.statuses),
|
|
15650
|
+
priorities: csvFlag(flags.priority || flags.priorities),
|
|
15651
|
+
types: csvFlag(flags.type || flags.types),
|
|
15652
|
+
sources: csvFlag(flags.source || flags.sources),
|
|
15653
|
+
workstream: flags.workstream,
|
|
15654
|
+
assignedTo: flags['assigned-to'] || flags.assignedTo,
|
|
15655
|
+
routedAgentId: flags['routed-to'] || flags.routedAgentId,
|
|
15656
|
+
unrouted: flagEnabled(flags.unrouted) || undefined,
|
|
15657
|
+
unassigned: flagEnabled(flags.unassigned) || undefined,
|
|
15658
|
+
// "Parked on a human" cannot be asked for with a status filter: `ask` parks a ticket in
|
|
15659
|
+
// pending_review, which also means "done, review me".
|
|
15660
|
+
awaitingResponse: flagEnabled(flags['awaiting-response'] || flags.awaitingResponse) || undefined,
|
|
15661
|
+
implementationAccountId: flags['implementation-account-id'] || flags.implementationAccountId,
|
|
15662
|
+
plannedIn: flags['planned-in'] || flags.plannedIn,
|
|
15663
|
+
boardStage: flags['board-stage'] || flags.boardStage,
|
|
15664
|
+
size: flags.size,
|
|
15665
|
+
blockedBy: flags['blocked-by'] || flags.blockedBy,
|
|
15666
|
+
rank: flags.rank,
|
|
15667
|
+
minRank: flags['min-rank'] || flags.minRank,
|
|
15668
|
+
maxRank: flags['max-rank'] || flags.maxRank,
|
|
15669
|
+
search: flags.search || flags.q,
|
|
15670
|
+
// Every one of these reads a field the platform already mirrored and already projected, and that
|
|
15671
|
+
// no surface could ask about. `syncState` in particular is how a broken integration is found:
|
|
15672
|
+
// a failed sync retries forever, so it sits in pendingSync looking exactly like a healthy one.
|
|
15673
|
+
syncStates: csvFlag(flags['sync-state'] || flags.syncState),
|
|
15674
|
+
overdue: flagEnabled(flags.overdue) || undefined,
|
|
15675
|
+
relatedTo: flags['related-to'] || flags.relatedTo,
|
|
15676
|
+
duplicateOf: flags['duplicate-of'] || flags.duplicateOf,
|
|
15677
|
+
causedBy: flags['caused-by'] || flags.causedBy,
|
|
15678
|
+
hasCause: flagEnabled(flags['has-cause'] || flags.hasCause) || undefined,
|
|
15679
|
+
dueBefore: flags['due-before'] || flags.dueBefore,
|
|
15680
|
+
dueAfter: flags['due-after'] || flags.dueAfter,
|
|
15681
|
+
requiresRepositoryWork: firstDefined(flags['repo-work'], flags['requires-repository-work']),
|
|
15682
|
+
// "Which tickets has the fleet already tried and handed back." A mirrored field with no filter is a
|
|
15683
|
+
// mirrored field nobody can act on — the same defect awaitingResponse and externalSyncState each had.
|
|
15684
|
+
minAutonomousRuns: parsePositiveInt(flags['min-runs'] || flags.minRuns, 0) || undefined,
|
|
15685
|
+
sortBy: flags['sort-by'] || flags.sortBy,
|
|
15686
|
+
sortDirection: flags['sort-direction'] || flags.sortDirection,
|
|
15687
|
+
includeChildren: flags['include-children'] === 'false' ? false : undefined,
|
|
15688
|
+
limit: parsePositiveInt(flags.limit, defaultLimit)
|
|
15689
|
+
};
|
|
15690
|
+
}
|
|
15691
|
+
|
|
15692
|
+
function ticketActivityMs(ticket) {
|
|
15693
|
+
for (const value of [
|
|
15694
|
+
ticket && ticket.updatedAt,
|
|
15695
|
+
ticket && ticket.updated,
|
|
15696
|
+
ticket && ticket.lastActivityAt,
|
|
15697
|
+
ticket && ticket.activityAt,
|
|
15698
|
+
ticket && ticket.claimedAt,
|
|
15699
|
+
ticket && ticket.assignedAt,
|
|
15700
|
+
ticket && ticket.createdAt
|
|
15701
|
+
]) {
|
|
15702
|
+
const parsed = Date.parse(value || '');
|
|
15703
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
15704
|
+
}
|
|
15705
|
+
return null;
|
|
15706
|
+
}
|
|
15707
|
+
|
|
15708
|
+
function ticketQueueLocalFilter(flags) {
|
|
15709
|
+
const inactiveDays = parsePositiveInt(firstDefined(
|
|
15710
|
+
flags['inactive-days'], flags.inactiveDays, flags['stale-days'], flags.staleDays
|
|
15711
|
+
), 0);
|
|
15712
|
+
if (inactiveDays) {
|
|
15713
|
+
const cutoffMs = Date.now() - inactiveDays * 24 * 60 * 60 * 1000;
|
|
15714
|
+
return {
|
|
15715
|
+
type: 'inactive-days',
|
|
15716
|
+
cutoffMs,
|
|
15717
|
+
description: 'inactive for at least ' + inactiveDays + ' day(s)',
|
|
15718
|
+
inactiveDays
|
|
15719
|
+
};
|
|
15720
|
+
}
|
|
15721
|
+
|
|
15722
|
+
const before = firstDefined(flags['updated-before'], flags.updatedBefore, flags['inactive-before'], flags.inactiveBefore);
|
|
15723
|
+
if (before) {
|
|
15724
|
+
const cutoffMs = Date.parse(String(before));
|
|
15725
|
+
if (!Number.isFinite(cutoffMs)) {
|
|
15726
|
+
throw new Error('--updated-before expects an ISO date/time or YYYY-MM-DD.');
|
|
15727
|
+
}
|
|
15728
|
+
return {
|
|
15729
|
+
type: 'updated-before',
|
|
15730
|
+
cutoffMs,
|
|
15731
|
+
description: 'updated before ' + new Date(cutoffMs).toISOString(),
|
|
15732
|
+
updatedBefore: new Date(cutoffMs).toISOString()
|
|
15733
|
+
};
|
|
15734
|
+
}
|
|
15735
|
+
return null;
|
|
15736
|
+
}
|
|
15737
|
+
|
|
15738
|
+
function decorateTicketQueueRows(tickets) {
|
|
15739
|
+
const nowMs = Date.now();
|
|
15740
|
+
return (tickets || []).map((ticket) => {
|
|
15741
|
+
const touchedMs = ticketActivityMs(ticket);
|
|
15742
|
+
return {
|
|
15743
|
+
...ticket,
|
|
15744
|
+
inactiveDays: Number.isFinite(touchedMs) ? Math.floor((nowMs - touchedMs) / (24 * 60 * 60 * 1000)) : null
|
|
15745
|
+
};
|
|
15746
|
+
});
|
|
15747
|
+
}
|
|
15748
|
+
|
|
15749
|
+
function recomputeTicketQueueCounts(tickets) {
|
|
15750
|
+
const counts = {
|
|
15751
|
+
total: (tickets || []).length,
|
|
15752
|
+
routed: 0,
|
|
15753
|
+
unrouted: 0,
|
|
15754
|
+
assigned: 0,
|
|
15755
|
+
unassigned: 0,
|
|
15756
|
+
awaitingResponse: 0,
|
|
15757
|
+
needsHuman: 0
|
|
15758
|
+
};
|
|
15759
|
+
for (const ticket of tickets || []) {
|
|
15760
|
+
if (ticket.routedAgentId || ticket.routedAgentLabel) counts.routed += 1;
|
|
15761
|
+
else counts.unrouted += 1;
|
|
15762
|
+
if (ticket.assignedTo) counts.assigned += 1;
|
|
15763
|
+
else counts.unassigned += 1;
|
|
15764
|
+
if (ticket.awaitingResponseSince) counts.awaitingResponse += 1;
|
|
15765
|
+
if (ticket.needsHuman || ticket.autonomousRunLimitReached || ticket.humanRequired) counts.needsHuman += 1;
|
|
15766
|
+
}
|
|
15767
|
+
return counts;
|
|
15768
|
+
}
|
|
15769
|
+
|
|
15770
|
+
function withTicketQueueLocalFilters(response, flags) {
|
|
15771
|
+
const localFilter = ticketQueueLocalFilter(flags);
|
|
15772
|
+
if (!localFilter) return response;
|
|
15773
|
+
const originalTickets = Array.isArray(response && response.tickets) ? response.tickets : [];
|
|
15774
|
+
const tickets = originalTickets.filter((ticket) => {
|
|
15775
|
+
const touchedMs = ticketActivityMs(ticket);
|
|
15776
|
+
return Number.isFinite(touchedMs) && touchedMs < localFilter.cutoffMs;
|
|
15777
|
+
});
|
|
15778
|
+
return {
|
|
15779
|
+
...(response || {}),
|
|
15780
|
+
tickets,
|
|
15781
|
+
count: tickets.length,
|
|
15782
|
+
totalCount: tickets.length,
|
|
15783
|
+
totalCountBeforeLocalFilters: response && response.totalCount != null ? response.totalCount : originalTickets.length,
|
|
15784
|
+
localFilters: {
|
|
15785
|
+
inactiveBefore: new Date(localFilter.cutoffMs).toISOString(),
|
|
15786
|
+
description: localFilter.description,
|
|
15787
|
+
pageScoped: true
|
|
15788
|
+
},
|
|
15789
|
+
counts: recomputeTicketQueueCounts(tickets)
|
|
15790
|
+
};
|
|
15791
|
+
}
|
|
15792
|
+
|
|
15793
|
+
async function fetchTicketQueueForAccount(context, accountId, flags, defaultLimit) {
|
|
15794
|
+
const response = await buildAxios(context.baseUrl, context.session.token, 30000)
|
|
15795
|
+
.post('/cli/tickets', buildTicketQueueRequestPayload(flags, accountId, context.dataMode, defaultLimit))
|
|
15796
|
+
.then((r) => r.data);
|
|
15797
|
+
const tickets = Array.isArray(response && response.tickets) ? decorateTicketQueueRows(response.tickets) : [];
|
|
15798
|
+
return withTicketQueueLocalFilters({
|
|
15799
|
+
...(response || {}),
|
|
15800
|
+
baseUrl: context.baseUrl,
|
|
15801
|
+
accountId,
|
|
15802
|
+
dataMode: (response && response.dataMode) || context.dataMode,
|
|
15803
|
+
compartment: context.baseUrl + ' · ' + ((response && response.dataMode) || context.dataMode) + ' lane',
|
|
15804
|
+
tickets,
|
|
15805
|
+
searchedAccounts: (response && response.accounts) || []
|
|
15806
|
+
}, flags);
|
|
15807
|
+
}
|
|
15808
|
+
|
|
15809
|
+
function globalTicketQueueAccountEntries(flags, context) {
|
|
15810
|
+
const ids = csvFlag(flags['account-ids'] || flags.accountIds);
|
|
15811
|
+
const wanted = ids ? new Set(ids.map((id) => String(Number(id))).filter((id) => id !== 'NaN')) : null;
|
|
15812
|
+
const byId = {};
|
|
15813
|
+
for (const entry of listAccountRepoEntries(readAccountRepoIndex())) {
|
|
15814
|
+
const id = Number(entry.accountId);
|
|
15815
|
+
if (!Number.isFinite(id) || id <= 0) continue;
|
|
15816
|
+
if (wanted && !wanted.has(String(id))) continue;
|
|
15817
|
+
if (!byId[String(id)]) byId[String(id)] = { ...entry, accountId: id };
|
|
15818
|
+
}
|
|
15819
|
+
if (!Object.keys(byId).length && context && context.accountId) {
|
|
15820
|
+
byId[String(context.accountId)] = {
|
|
15821
|
+
accountId: Number(context.accountId),
|
|
15822
|
+
name: (context.session && context.session.account && context.session.account.name) || null,
|
|
15823
|
+
source: 'session'
|
|
15824
|
+
};
|
|
15825
|
+
}
|
|
15826
|
+
const breadth = (entry) => {
|
|
15827
|
+
const type = String(entry.type || '').toUpperCase();
|
|
15828
|
+
if (type === 'PLATFORM') return 0;
|
|
15829
|
+
if (type === 'PRODUCT') return 1;
|
|
15830
|
+
return 2;
|
|
15831
|
+
};
|
|
15832
|
+
return Object.values(byId).sort((a, b) => breadth(a) - breadth(b) || Number(a.accountId) - Number(b.accountId));
|
|
15833
|
+
}
|
|
15834
|
+
|
|
15835
|
+
function aggregateGlobalTicketQueues(queues) {
|
|
15836
|
+
const compartments = supportTicketCompartmentGroups(queues);
|
|
15837
|
+
const tickets = compartments.flatMap((group) => group.tickets.map((ticket) => ({
|
|
15838
|
+
...ticket,
|
|
15839
|
+
compartment: group.compartment
|
|
15840
|
+
})));
|
|
15841
|
+
return {
|
|
15842
|
+
compartments: compartments.map((group) => ({
|
|
15843
|
+
compartment: group.compartment,
|
|
15844
|
+
baseUrl: group.baseUrl,
|
|
15845
|
+
dataMode: group.dataMode,
|
|
15846
|
+
queueCount: group.independent.length,
|
|
15847
|
+
overlappingQueuesDropped: group.queues.length - group.independent.length,
|
|
15848
|
+
scopeUnknown: group.independent.some((queue) => !(queue.searchedAccounts || []).length),
|
|
15849
|
+
retrievedCount: group.tickets.length,
|
|
15850
|
+
accounts: group.independent.map((queue) => ({
|
|
15851
|
+
accountId: queue.accountId,
|
|
15852
|
+
open: Number(queue.totalCount || 0),
|
|
15853
|
+
scanned: (queue.searchedAccounts || []).length
|
|
15854
|
+
}))
|
|
15855
|
+
})),
|
|
15856
|
+
tickets
|
|
15857
|
+
};
|
|
15858
|
+
}
|
|
15859
|
+
|
|
15860
|
+
function printTicketQueueRows(tickets) {
|
|
15861
|
+
const showsBoard = (tickets || []).some((t) => t.boardStage || t.plannedIn || t.blockedBy);
|
|
15862
|
+
for (const t of tickets || []) {
|
|
15863
|
+
// The repo account, not just the owning account: it is what the edit lease excludes on, so it is
|
|
15864
|
+
// what tells a reader which of these can be worked at the same time.
|
|
15865
|
+
const repo = t.implementationAccountId || t.accountId;
|
|
15866
|
+
const who = t.routedAgentLabel || t.routedAgentId || t.assignedTo || '-';
|
|
15867
|
+
console.log(' ' + String(t.ticketId || t.id).padStart(6) + ' ' +
|
|
15868
|
+
String(t.status || '').padEnd(14) + String(t.priority || '').padEnd(9) +
|
|
15869
|
+
'repo ' + String(repo || '?').padEnd(6) +
|
|
15870
|
+
String(who).slice(0, 18).padEnd(20) + String(t.subject || '').slice(0, 58));
|
|
15871
|
+
// A second line rather than more columns: these are the account's own labels, of unbounded length,
|
|
15872
|
+
// and truncating `awaiting_customer_confirmation` to fit a column makes it unrecognisable.
|
|
15873
|
+
const marks = [
|
|
15874
|
+
t.inactiveDays != null ? 'inactive ' + t.inactiveDays + 'd' : '',
|
|
15875
|
+
t.awaitingResponseSince ? 'WAITING ON A HUMAN' : '',
|
|
15876
|
+
t.autonomousRuns > 1 ? 'run ' + t.autonomousRuns + ' by workers' : '',
|
|
15877
|
+
showsBoard && t.boardStage ? 'stage ' + t.boardStage : '',
|
|
15878
|
+
showsBoard && t.plannedIn ? 'in ' + t.plannedIn : '',
|
|
15879
|
+
showsBoard && t.blockedBy ? 'BLOCKED BY ' + t.blockedBy : ''
|
|
15880
|
+
].filter(Boolean);
|
|
15881
|
+
if (marks.length) console.log(' '.repeat(10) + marks.join(' '));
|
|
15882
|
+
}
|
|
15883
|
+
}
|
|
15884
|
+
|
|
15885
|
+
async function ticketGlobalQueueCommand(flags) {
|
|
15886
|
+
const local = listLocalAgents()[0] || null;
|
|
15887
|
+
const context = agentSessionFor(flags, local);
|
|
15888
|
+
const entries = globalTicketQueueAccountEntries(flags, context);
|
|
15889
|
+
if (!entries.length) {
|
|
15890
|
+
throw new Error('ticket queue --all-accounts needs indexed account repos. Run `remits-cli agent repos --rescan`.');
|
|
15891
|
+
}
|
|
15892
|
+
|
|
15893
|
+
const queues = [];
|
|
15894
|
+
const errors = [];
|
|
15895
|
+
const defaultLimit = parsePositiveInt(flags.limit, 500);
|
|
15896
|
+
const concurrency = Math.min(Math.max(parsePositiveInt(flags.concurrency, 4), 1), 8);
|
|
15897
|
+
let cursor = 0;
|
|
15898
|
+
async function worker() {
|
|
15899
|
+
while (cursor < entries.length) {
|
|
15900
|
+
const entry = entries[cursor++];
|
|
15901
|
+
try {
|
|
15902
|
+
const queue = await fetchTicketQueueForAccount(context, Number(entry.accountId), flags, defaultLimit);
|
|
15903
|
+
queues.push({
|
|
15904
|
+
...queue,
|
|
15905
|
+
accountName: entry.name || queue.accountName || null
|
|
15906
|
+
});
|
|
15907
|
+
} catch (err) {
|
|
15908
|
+
errors.push({
|
|
15909
|
+
accountId: Number(entry.accountId),
|
|
15910
|
+
accountName: entry.name || null,
|
|
15911
|
+
message: describeError(err)
|
|
15912
|
+
});
|
|
15913
|
+
}
|
|
15914
|
+
}
|
|
15915
|
+
}
|
|
15916
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, entries.length) }, () => worker()));
|
|
15917
|
+
|
|
15918
|
+
const aggregate = aggregateGlobalTicketQueues(queues);
|
|
15919
|
+
const tickets = decorateTicketQueueRows(aggregate.tickets)
|
|
15920
|
+
.sort((a, b) => (ticketActivityMs(b) || 0) - (ticketActivityMs(a) || 0));
|
|
15921
|
+
const counts = recomputeTicketQueueCounts(tickets);
|
|
15922
|
+
const response = {
|
|
15923
|
+
success: queues.length > 0,
|
|
15924
|
+
partial: errors.length > 0,
|
|
15925
|
+
global: true,
|
|
15926
|
+
baseUrl: context.baseUrl,
|
|
15927
|
+
dataMode: context.dataMode,
|
|
15928
|
+
accountCount: entries.length,
|
|
15929
|
+
queueCount: queues.length,
|
|
15930
|
+
errorCount: errors.length,
|
|
15931
|
+
totalCount: tickets.length,
|
|
15932
|
+
counts,
|
|
15933
|
+
localFilters: queues.find((queue) => queue.localFilters)?.localFilters || undefined,
|
|
15934
|
+
compartments: aggregate.compartments,
|
|
15935
|
+
accountErrors: errors,
|
|
15936
|
+
tickets
|
|
15937
|
+
};
|
|
15938
|
+
|
|
15939
|
+
if (flagEnabled(flags.json)) {
|
|
15940
|
+
console.log(JSON.stringify(response, null, 2));
|
|
15941
|
+
return;
|
|
15942
|
+
}
|
|
15943
|
+
|
|
15944
|
+
if (!tickets.length) {
|
|
15945
|
+
console.log('No tickets match across ' + entries.length + ' indexed account queue(s). [' +
|
|
15946
|
+
context.dataMode + ' lane]');
|
|
15947
|
+
if (errors.length) {
|
|
15948
|
+
console.log(errors.length + ' account queue(s) could not be read; use --json for details.');
|
|
15949
|
+
}
|
|
15950
|
+
return;
|
|
15951
|
+
}
|
|
15952
|
+
console.log(tickets.length + ' unique ticket(s) across ' + queues.length + '/' + entries.length +
|
|
15953
|
+
' account queue(s) — ' + (counts.unrouted || 0) + ' unrouted, ' + (counts.unassigned || 0) +
|
|
15954
|
+
' unassigned, ' + (counts.awaitingResponse || 0) + ' waiting on a human' +
|
|
15955
|
+
((counts.needsHuman || 0) ? ', ' + counts.needsHuman + ' NEEDS A PERSON' : '') +
|
|
15956
|
+
' [' + context.dataMode + ' lane]');
|
|
15957
|
+
if (response.localFilters) {
|
|
15958
|
+
console.log(' local filter: ' + response.localFilters.description +
|
|
15959
|
+
' (applied to retrieved rows; raise --limit if a root queue was truncated)');
|
|
15960
|
+
}
|
|
15961
|
+
if (errors.length) {
|
|
15962
|
+
console.log(' ' + errors.length + ' account queue(s) could not be read; use --json for details.');
|
|
15963
|
+
}
|
|
15964
|
+
console.log('');
|
|
15965
|
+
printTicketQueueRows(tickets);
|
|
15966
|
+
}
|
|
15967
|
+
|
|
15431
15968
|
/**
|
|
15432
15969
|
* The ticket queue — what else is open, and what it looks like as a whole.
|
|
15433
15970
|
*
|
|
@@ -15441,6 +15978,10 @@ async function ticketCommand(flags, subcommand) {
|
|
|
15441
15978
|
* was the local dashboard.</p>
|
|
15442
15979
|
*/
|
|
15443
15980
|
async function ticketQueueCommand(flags) {
|
|
15981
|
+
if (flagEnabled(flags['all-accounts']) || flagEnabled(flags.allAccounts) || flagEnabled(flags.global)) {
|
|
15982
|
+
return ticketGlobalQueueCommand(flags);
|
|
15983
|
+
}
|
|
15984
|
+
|
|
15444
15985
|
const local = listLocalAgents()[0] || null;
|
|
15445
15986
|
const context = agentSessionFor(flags, local);
|
|
15446
15987
|
const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
|
|
@@ -15448,46 +15989,7 @@ async function ticketQueueCommand(flags) {
|
|
|
15448
15989
|
throw new Error('ticket queue needs --account-id (or run it from an account repo).');
|
|
15449
15990
|
}
|
|
15450
15991
|
|
|
15451
|
-
const
|
|
15452
|
-
const response = await buildAxios(context.baseUrl, context.session.token, 30000)
|
|
15453
|
-
.post('/cli/tickets', {
|
|
15454
|
-
accountId,
|
|
15455
|
-
dataMode: context.dataMode,
|
|
15456
|
-
statuses: csv(flags.status || flags.statuses),
|
|
15457
|
-
priorities: csv(flags.priority || flags.priorities),
|
|
15458
|
-
types: csv(flags.type || flags.types),
|
|
15459
|
-
sources: csv(flags.source || flags.sources),
|
|
15460
|
-
workstream: flags.workstream,
|
|
15461
|
-
assignedTo: flags['assigned-to'] || flags.assignedTo,
|
|
15462
|
-
routedAgentId: flags['routed-to'] || flags.routedAgentId,
|
|
15463
|
-
unrouted: flagEnabled(flags.unrouted) || undefined,
|
|
15464
|
-
unassigned: flagEnabled(flags.unassigned) || undefined,
|
|
15465
|
-
// "Parked on a human" cannot be asked for with a status filter: `ask` parks a ticket in
|
|
15466
|
-
// pending_review, which also means "done, review me".
|
|
15467
|
-
awaitingResponse: flagEnabled(flags['awaiting-response'] || flags.awaitingResponse) || undefined,
|
|
15468
|
-
plannedIn: flags['planned-in'] || flags.plannedIn,
|
|
15469
|
-
boardStage: flags['board-stage'] || flags.boardStage,
|
|
15470
|
-
blockedBy: flags['blocked-by'] || flags.blockedBy,
|
|
15471
|
-
search: flags.search || flags.q,
|
|
15472
|
-
// Every one of these reads a field the platform already mirrored and already projected, and that
|
|
15473
|
-
// no surface could ask about. `syncState` in particular is how a broken integration is found:
|
|
15474
|
-
// a failed sync retries forever, so it sits in pendingSync looking exactly like a healthy one.
|
|
15475
|
-
syncStates: csv(flags['sync-state'] || flags.syncState),
|
|
15476
|
-
overdue: flagEnabled(flags.overdue) || undefined,
|
|
15477
|
-
relatedTo: flags['related-to'] || flags.relatedTo,
|
|
15478
|
-
duplicateOf: flags['duplicate-of'] || flags.duplicateOf,
|
|
15479
|
-
causedBy: flags['caused-by'] || flags.causedBy,
|
|
15480
|
-
hasCause: flagEnabled(flags['has-cause'] || flags.hasCause) || undefined,
|
|
15481
|
-
dueBefore: flags['due-before'] || flags.dueBefore,
|
|
15482
|
-
dueAfter: flags['due-after'] || flags.dueAfter,
|
|
15483
|
-
// "Which tickets has the fleet already tried and handed back." A mirrored field with no filter is a
|
|
15484
|
-
// mirrored field nobody can act on — the same defect awaitingResponse and externalSyncState each had.
|
|
15485
|
-
minAutonomousRuns: parsePositiveInt(flags['min-runs'] || flags.minRuns, 0) || undefined,
|
|
15486
|
-
sortBy: flags['sort-by'] || flags.sortBy,
|
|
15487
|
-
sortDirection: flags['sort-direction'] || flags.sortDirection,
|
|
15488
|
-
includeChildren: flags['include-children'] === 'false' ? false : undefined,
|
|
15489
|
-
limit: parsePositiveInt(flags.limit, 25)
|
|
15490
|
-
}).then((r) => r.data);
|
|
15992
|
+
const response = await fetchTicketQueueForAccount(context, accountId, flags, 25);
|
|
15491
15993
|
|
|
15492
15994
|
if (flagEnabled(flags.json)) {
|
|
15493
15995
|
console.log(JSON.stringify(response, null, 2));
|
|
@@ -15512,30 +16014,12 @@ async function ticketQueueCommand(flags) {
|
|
|
15512
16014
|
'and are no longer offered to workers.');
|
|
15513
16015
|
console.log(' Find them: remits-cli ticket queue --min-runs 1 --unassigned (then answer, reopen, or route one by hand)');
|
|
15514
16016
|
}
|
|
15515
|
-
|
|
15516
|
-
|
|
15517
|
-
|
|
15518
|
-
const showsBoard = tickets.some((t) => t.boardStage || t.plannedIn || t.blockedBy);
|
|
15519
|
-
for (const t of tickets) {
|
|
15520
|
-
// The repo account, not just the owning account: it is what the edit lease excludes on, so it is
|
|
15521
|
-
// what tells a reader which of these can be worked at the same time.
|
|
15522
|
-
const repo = t.implementationAccountId || t.accountId;
|
|
15523
|
-
const who = t.routedAgentLabel || t.routedAgentId || t.assignedTo || '—';
|
|
15524
|
-
console.log(' ' + String(t.ticketId || t.id).padStart(6) + ' ' +
|
|
15525
|
-
String(t.status || '').padEnd(14) + String(t.priority || '').padEnd(9) +
|
|
15526
|
-
'repo ' + String(repo || '?').padEnd(6) +
|
|
15527
|
-
String(who).slice(0, 18).padEnd(20) + String(t.subject || '').slice(0, 58));
|
|
15528
|
-
// A second line rather than more columns: these are the account's own labels, of unbounded length,
|
|
15529
|
-
// and truncating `awaiting_customer_confirmation` to fit a column makes it unrecognisable.
|
|
15530
|
-
const marks = [
|
|
15531
|
-
t.awaitingResponseSince ? 'WAITING ON A HUMAN' : '',
|
|
15532
|
-
t.autonomousRuns > 1 ? 'run ' + t.autonomousRuns + ' by workers' : '',
|
|
15533
|
-
showsBoard && t.boardStage ? 'stage ' + t.boardStage : '',
|
|
15534
|
-
showsBoard && t.plannedIn ? 'in ' + t.plannedIn : '',
|
|
15535
|
-
showsBoard && t.blockedBy ? 'BLOCKED BY ' + t.blockedBy : ''
|
|
15536
|
-
].filter(Boolean);
|
|
15537
|
-
if (marks.length) console.log(' '.repeat(10) + marks.join(' '));
|
|
16017
|
+
if (response.localFilters) {
|
|
16018
|
+
console.log(' local filter: ' + response.localFilters.description +
|
|
16019
|
+
' (applied to retrieved rows; raise --limit if the queue was truncated)');
|
|
15538
16020
|
}
|
|
16021
|
+
console.log('');
|
|
16022
|
+
printTicketQueueRows(tickets);
|
|
15539
16023
|
if (response.totalCount > tickets.length) {
|
|
15540
16024
|
console.log('');
|
|
15541
16025
|
console.log(' showing ' + tickets.length + ' of ' + response.totalCount + ' — raise --limit to see more');
|
|
@@ -15690,12 +16174,16 @@ async function resolveTicketAccountId(context, ticketId) {
|
|
|
15690
16174
|
function printTicketHelp() {
|
|
15691
16175
|
console.log('Usage: remits-cli ticket <action> --ticket ID');
|
|
15692
16176
|
console.log('');
|
|
15693
|
-
console.log(' queue [--account-id ID] [--status open,accepted] [--unrouted] [--search "..."]');
|
|
16177
|
+
console.log(' queue [--account-id ID|--all-accounts] [--status open,accepted] [--unrouted] [--search "..."]');
|
|
15694
16178
|
console.log(' What else is open. Read it BEFORE deciding your ticket is unique — the same fix often');
|
|
15695
16179
|
console.log(' appears a dozen times, and the "repo" column is the account the edit lease excludes');
|
|
15696
16180
|
console.log(' on, so it also tells you which of these can be worked at the same time.');
|
|
15697
16181
|
console.log(' Also: --priority --type --workstream --assigned-to --unassigned --board-stage');
|
|
15698
16182
|
console.log(' --planned-in --blocked-by --sort-by --sort-direction --limit --json');
|
|
16183
|
+
console.log(' --all-accounts scans indexed account repos, includes descendants, drops overlapping');
|
|
16184
|
+
console.log(' child queues, and keeps per-account read failures in --json instead of aborting.');
|
|
16185
|
+
console.log(' Add --account-ids 4,11 to narrow a sweep, or --inactive-days N /');
|
|
16186
|
+
console.log(' --updated-before YYYY-MM-DD to list stale cleanup candidates.');
|
|
15699
16187
|
console.log(' --awaiting-response lists only tickets parked on a human. A status filter cannot');
|
|
15700
16188
|
console.log(' ask that: `ask` parks a ticket in pending_review, which also means "done, review me".');
|
|
15701
16189
|
console.log(' --overdue lists tickets past a due time their account committed to; --sort-by due');
|
|
@@ -15827,8 +16315,10 @@ function describeAgentScope(record) {
|
|
|
15827
16315
|
if (!record) return null;
|
|
15828
16316
|
const serves = Array.isArray(record.serves) ? record.serves.filter(Boolean) : [];
|
|
15829
16317
|
const readOnly = record.mode === 'investigate' || record.canEdit === false;
|
|
15830
|
-
|
|
16318
|
+
const role = record.role || record.persona;
|
|
16319
|
+
if (!serves.length && !readOnly && !role) return null;
|
|
15831
16320
|
const parts = [];
|
|
16321
|
+
if (role) parts.push('role: ' + role + (record.persona && record.persona !== role ? ' [' + record.persona + ']' : ''));
|
|
15832
16322
|
parts.push(serves.length ? 'serves ' + serves.join(', ') : 'serves every workstream');
|
|
15833
16323
|
parts.push(readOnly ? 'investigate only (never edits or stages)' : 'may edit');
|
|
15834
16324
|
return parts.join(' · ');
|
|
@@ -16594,6 +17084,203 @@ async function agentReleaseCommand(flags) {
|
|
|
16594
17084
|
}
|
|
16595
17085
|
}
|
|
16596
17086
|
|
|
17087
|
+
async function agentPruneWorktreesCommand(flags) {
|
|
17088
|
+
const dryRun = flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun);
|
|
17089
|
+
const force = flagEnabled(flags.force);
|
|
17090
|
+
const accountIdFilter = flags['account-id'] ? Number(flags['account-id']) : null;
|
|
17091
|
+
|
|
17092
|
+
try { updateAccountRepoIndex(process.cwd()); } catch (_) {}
|
|
17093
|
+
try { discoverAccountRepos(); } catch (_) {}
|
|
17094
|
+
|
|
17095
|
+
const candidateDirs = new Set();
|
|
17096
|
+
const cwdIdentity = accountCheckoutIdentity(process.cwd());
|
|
17097
|
+
if (fs.existsSync(process.cwd()) &&
|
|
17098
|
+
(!accountIdFilter || (cwdIdentity && cwdIdentity.accountId === accountIdFilter))) {
|
|
17099
|
+
candidateDirs.add(path.resolve(process.cwd()));
|
|
17100
|
+
}
|
|
17101
|
+
try {
|
|
17102
|
+
for (const entry of listAccountRepoEntries(readAccountRepoIndex())) {
|
|
17103
|
+
if (entry && entry.directory && fs.existsSync(entry.directory)) {
|
|
17104
|
+
if (accountIdFilter && Number(entry.accountId) !== accountIdFilter) {
|
|
17105
|
+
continue;
|
|
17106
|
+
}
|
|
17107
|
+
candidateDirs.add(path.resolve(entry.directory));
|
|
17108
|
+
}
|
|
17109
|
+
}
|
|
17110
|
+
} catch (_) {}
|
|
17111
|
+
|
|
17112
|
+
const workerStore = readWorkerStore();
|
|
17113
|
+
const isTicketActive = (ticketId) => {
|
|
17114
|
+
if (!ticketId) return false;
|
|
17115
|
+
for (const agentWorkers of Object.values(workerStore.agents || {})) {
|
|
17116
|
+
const worker = agentWorkers[ticketId];
|
|
17117
|
+
if (worker && worker.pid && processIsAlive(worker.pid)) {
|
|
17118
|
+
return true;
|
|
17119
|
+
}
|
|
17120
|
+
}
|
|
17121
|
+
return false;
|
|
17122
|
+
};
|
|
17123
|
+
|
|
17124
|
+
const results = [];
|
|
17125
|
+
for (const baseDir of candidateDirs) {
|
|
17126
|
+
const worktreeRoot = path.join(baseDir, '.remits-cli', TICKET_WORKTREE_DIRNAME);
|
|
17127
|
+
if (!fs.existsSync(worktreeRoot)) {
|
|
17128
|
+
continue;
|
|
17129
|
+
}
|
|
17130
|
+
let subdirs = [];
|
|
17131
|
+
try {
|
|
17132
|
+
subdirs = fs.readdirSync(worktreeRoot, { withFileTypes: true });
|
|
17133
|
+
} catch (_) {
|
|
17134
|
+
continue;
|
|
17135
|
+
}
|
|
17136
|
+
|
|
17137
|
+
for (const dirent of subdirs) {
|
|
17138
|
+
if (!dirent.isDirectory()) continue;
|
|
17139
|
+
const folderName = dirent.name;
|
|
17140
|
+
const worktreePath = path.join(worktreeRoot, folderName);
|
|
17141
|
+
const ticketId = folderName.startsWith('ticket-') ? folderName.replace(/^ticket-/, '') : folderName;
|
|
17142
|
+
|
|
17143
|
+
if (isTicketActive(ticketId)) {
|
|
17144
|
+
results.push({
|
|
17145
|
+
baseDir,
|
|
17146
|
+
worktreePath,
|
|
17147
|
+
ticketId,
|
|
17148
|
+
status: 'skipped',
|
|
17149
|
+
reason: 'active_worker_process'
|
|
17150
|
+
});
|
|
17151
|
+
continue;
|
|
17152
|
+
}
|
|
17153
|
+
|
|
17154
|
+
// Safety is decided from what git ANSWERS, never from its silence: a worktree git cannot report on
|
|
17155
|
+
// is left alone, because removing it is the one outcome that cannot be undone.
|
|
17156
|
+
let isDirty = false;
|
|
17157
|
+
let unreachable = false;
|
|
17158
|
+
try {
|
|
17159
|
+
// Inside the launch checkout, a directory that is NOT its own worktree answers with the PARENT
|
|
17160
|
+
// repository's status — a clean answer about the wrong tree.
|
|
17161
|
+
const topLevel = execSync('git rev-parse --show-toplevel', { cwd: worktreePath, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
17162
|
+
if (fs.realpathSync(topLevel) !== fs.realpathSync(worktreePath)) {
|
|
17163
|
+
results.push({ baseDir, worktreePath, ticketId, status: 'skipped', reason: 'not_a_worktree' });
|
|
17164
|
+
continue;
|
|
17165
|
+
}
|
|
17166
|
+
const status = execSync('git status --porcelain', { cwd: worktreePath, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
17167
|
+
isDirty = status.length > 0;
|
|
17168
|
+
// Commits no branch, tag or remote-tracking ref holds — a worker on a detached HEAD that committed
|
|
17169
|
+
// and never pushed. Removing the worktree removes the only name they have, and --force does not
|
|
17170
|
+
// override this: those commits are work, not scratch.
|
|
17171
|
+
const orphaned = execSync('git rev-list --max-count=1 HEAD --not --branches --tags --remotes',
|
|
17172
|
+
{ cwd: worktreePath, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
17173
|
+
unreachable = orphaned.length > 0;
|
|
17174
|
+
} catch (_) {
|
|
17175
|
+
results.push({ baseDir, worktreePath, ticketId, status: 'skipped', reason: 'git_status_unreadable' });
|
|
17176
|
+
continue;
|
|
17177
|
+
}
|
|
17178
|
+
|
|
17179
|
+
if (unreachable) {
|
|
17180
|
+
results.push({ baseDir, worktreePath, ticketId, status: 'skipped', reason: 'unpushed_commits' });
|
|
17181
|
+
continue;
|
|
17182
|
+
}
|
|
17183
|
+
|
|
17184
|
+
if (isDirty && !force) {
|
|
17185
|
+
results.push({
|
|
17186
|
+
baseDir,
|
|
17187
|
+
worktreePath,
|
|
17188
|
+
ticketId,
|
|
17189
|
+
status: 'skipped',
|
|
17190
|
+
reason: 'uncommitted_changes'
|
|
17191
|
+
});
|
|
17192
|
+
continue;
|
|
17193
|
+
}
|
|
17194
|
+
|
|
17195
|
+
if (dryRun) {
|
|
17196
|
+
results.push({
|
|
17197
|
+
baseDir,
|
|
17198
|
+
worktreePath,
|
|
17199
|
+
ticketId,
|
|
17200
|
+
status: 'would_prune',
|
|
17201
|
+
dirty: isDirty
|
|
17202
|
+
});
|
|
17203
|
+
continue;
|
|
17204
|
+
}
|
|
17205
|
+
|
|
17206
|
+
let removed = false;
|
|
17207
|
+
let error = null;
|
|
17208
|
+
try {
|
|
17209
|
+
execSync('git worktree remove --force ' + shellQuote(worktreePath), { cwd: baseDir, stdio: ['ignore', 'ignore', 'ignore'] });
|
|
17210
|
+
removed = true;
|
|
17211
|
+
} catch (_) {}
|
|
17212
|
+
|
|
17213
|
+
if (fs.existsSync(worktreePath)) {
|
|
17214
|
+
try {
|
|
17215
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
17216
|
+
removed = true;
|
|
17217
|
+
} catch (err) {
|
|
17218
|
+
error = describeError(err);
|
|
17219
|
+
}
|
|
17220
|
+
}
|
|
17221
|
+
|
|
17222
|
+
try {
|
|
17223
|
+
execSync('git worktree prune', { cwd: baseDir, stdio: ['ignore', 'ignore', 'ignore'] });
|
|
17224
|
+
} catch (_) {}
|
|
17225
|
+
|
|
17226
|
+
if (removed) {
|
|
17227
|
+
results.push({
|
|
17228
|
+
baseDir,
|
|
17229
|
+
worktreePath,
|
|
17230
|
+
ticketId,
|
|
17231
|
+
status: 'pruned',
|
|
17232
|
+
dirty: isDirty
|
|
17233
|
+
});
|
|
17234
|
+
} else {
|
|
17235
|
+
results.push({
|
|
17236
|
+
baseDir,
|
|
17237
|
+
worktreePath,
|
|
17238
|
+
ticketId,
|
|
17239
|
+
status: 'failed',
|
|
17240
|
+
error: error || 'Could not remove directory'
|
|
17241
|
+
});
|
|
17242
|
+
}
|
|
17243
|
+
}
|
|
17244
|
+
|
|
17245
|
+
try {
|
|
17246
|
+
execSync('git worktree prune', { cwd: baseDir, stdio: ['ignore', 'ignore', 'ignore'] });
|
|
17247
|
+
} catch (_) {}
|
|
17248
|
+
}
|
|
17249
|
+
|
|
17250
|
+
if (flagEnabled(flags.json)) {
|
|
17251
|
+
console.log(JSON.stringify({ dryRun, results }, null, 2));
|
|
17252
|
+
return;
|
|
17253
|
+
}
|
|
17254
|
+
|
|
17255
|
+
if (!results.length) {
|
|
17256
|
+
console.log('No ticket worktrees found to prune.');
|
|
17257
|
+
return;
|
|
17258
|
+
}
|
|
17259
|
+
|
|
17260
|
+
const pruned = results.filter((r) => r.status === 'pruned' || r.status === 'would_prune');
|
|
17261
|
+
const skipped = results.filter((r) => r.status === 'skipped');
|
|
17262
|
+
const failed = results.filter((r) => r.status === 'failed');
|
|
17263
|
+
|
|
17264
|
+
console.log((dryRun ? 'DRY RUN: ' : '') + 'Worktree prune results:');
|
|
17265
|
+
for (const item of results) {
|
|
17266
|
+
const label = item.status === 'pruned' ? 'PRUNED'
|
|
17267
|
+
: item.status === 'would_prune' ? 'WOULD PRUNE'
|
|
17268
|
+
: item.status === 'skipped' ? 'SKIPPED (' + item.reason + ')'
|
|
17269
|
+
: 'FAILED (' + (item.error || 'unknown') + ')';
|
|
17270
|
+
console.log(' [' + label + '] ' + item.worktreePath + ' (ticket ' + item.ticketId + ')');
|
|
17271
|
+
}
|
|
17272
|
+
console.log('');
|
|
17273
|
+
console.log('Summary: ' + pruned.length + ' ' + (dryRun ? 'eligible' : 'pruned') + ', ' +
|
|
17274
|
+
skipped.length + ' skipped, ' + failed.length + ' failed.');
|
|
17275
|
+
if (skipped.some((s) => s.reason === 'uncommitted_changes')) {
|
|
17276
|
+
console.log('Tip: pass --force to remove worktrees with uncommitted changes.');
|
|
17277
|
+
}
|
|
17278
|
+
if (skipped.some((s) => s.reason === 'unpushed_commits')) {
|
|
17279
|
+
console.log('Worktrees holding commits no branch or remote has are never removed. Push them, or give');
|
|
17280
|
+
console.log('them a branch (git -C <worktree> branch <name>), then prune again.');
|
|
17281
|
+
}
|
|
17282
|
+
}
|
|
17283
|
+
|
|
16597
17284
|
async function agentCommand(flags, subcommand) {
|
|
16598
17285
|
switch (subcommand) {
|
|
16599
17286
|
case 'register': return agentRegisterCommand(flags);
|
|
@@ -16606,6 +17293,7 @@ async function agentCommand(flags, subcommand) {
|
|
|
16606
17293
|
case 'inspect': case 'activity': return activityInspectCommand(flags);
|
|
16607
17294
|
case 'repos': case 'repo': return agentReposCommand(flags);
|
|
16608
17295
|
case 'release': case 'deregister': case 'stop': return agentReleaseCommand(flags);
|
|
17296
|
+
case 'prune-worktrees': case 'prune': return agentPruneWorktreesCommand(flags);
|
|
16609
17297
|
case 'heartbeat-daemon': return agentHeartbeatDaemonCommand(flags);
|
|
16610
17298
|
default:
|
|
16611
17299
|
printAgentHelp();
|
|
@@ -16616,14 +17304,17 @@ async function agentCommand(flags, subcommand) {
|
|
|
16616
17304
|
function printAgentHelp() {
|
|
16617
17305
|
console.log('Usage: remits-cli agent <subcommand>');
|
|
16618
17306
|
console.log('');
|
|
16619
|
-
console.log(' serve [--worker-agent claude|codex|gemini] [--
|
|
16620
|
-
console.log(' [--serves support,incident,sdlc]');
|
|
17307
|
+
console.log(' serve [--worker-agent claude|codex|gemini] [--role ROLE] [--persona PERSONA]');
|
|
17308
|
+
console.log(' [--max-concurrent N] [--mode edit|investigate] [--serves support,incident,sdlc]');
|
|
16621
17309
|
console.log(' Run this in a PLAIN TERMINAL TAB — that tab becomes the agent host. It returns');
|
|
16622
17310
|
console.log(' immediately; a background supervisor launches ONE fresh headless agent process per');
|
|
16623
17311
|
console.log(' routed ticket, in that account\'s repo, and that process exits when the ticket is');
|
|
16624
17312
|
console.log(' done. Nothing accumulates context across tickets, so no session needs clearing.');
|
|
16625
17313
|
console.log(' Closing the tab stops the agent.');
|
|
16626
17314
|
console.log('');
|
|
17315
|
+
console.log(' --role / --persona declares the session\'s operational role (e.g. developer, triage,');
|
|
17316
|
+
console.log(' release_manager) — opaque names your OPERATIONS remits-policy rules match. Fixed for the');
|
|
17317
|
+
console.log(' life of the session, and limited by your user\'s allowedAgentRoles.');
|
|
16627
17318
|
console.log(' --worker-agent picks WHICH AI does the work. Without it: whatever agent this');
|
|
16628
17319
|
console.log(' session is running (when you start it from inside one), else your');
|
|
16629
17320
|
console.log(' `remits-cli config set --agent NAME` default. It prints which it chose and why.');
|
|
@@ -16638,7 +17329,7 @@ function printAgentHelp() {
|
|
|
16638
17329
|
console.log('');
|
|
16639
17330
|
console.log(' workers [--json] What this session is running right now.');
|
|
16640
17331
|
console.log('');
|
|
16641
|
-
console.log(' register [--label NAME] [--data-mode test|prod] [--base-url URL] [--serves a,b]');
|
|
17332
|
+
console.log(' register [--label NAME] [--role ROLE] [--data-mode test|prod] [--base-url URL] [--serves a,b]');
|
|
16642
17333
|
console.log(' Presence only — make THIS session routable without starting anything. Use it when a');
|
|
16643
17334
|
console.log(' human, or the agent in this very tab, will work the tickets by hand. Runs from ANY');
|
|
16644
17335
|
console.log(' directory — it registers for every account repo indexed on this machine that your');
|
|
@@ -16666,6 +17357,9 @@ function printAgentHelp() {
|
|
|
16666
17357
|
console.log(' export looks identical to the scanner) and any account claimed by two checkouts.');
|
|
16667
17358
|
console.log(' Correct it with --account-id <id> --directory <path>, or --account-id <id> --forget');
|
|
16668
17359
|
console.log(' to make that account\'s tickets park instead of being worked somewhere wrong.');
|
|
17360
|
+
console.log(' prune-worktrees [--account-id ID] [--dry-run] [--force] [--json]');
|
|
17361
|
+
console.log(' Safely removes stale or finished ticket worktrees in .remits-cli/ticket-worktrees.');
|
|
17362
|
+
console.log(' Skips worktrees for active workers and uncommitted changes (unless --force).');
|
|
16669
17363
|
console.log(' release Deregister this session immediately.');
|
|
16670
17364
|
console.log('');
|
|
16671
17365
|
console.log(' After `register`, the other subcommands need no flags: they reuse the platform, lane');
|
|
@@ -17106,7 +17800,7 @@ function printComponentsHelp(subcommand) {
|
|
|
17106
17800
|
return;
|
|
17107
17801
|
}
|
|
17108
17802
|
if (subcommand === 'sync') {
|
|
17109
|
-
console.log('Usage: remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--create-variant-branch] [--summary] [--json]');
|
|
17803
|
+
console.log('Usage: remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--create-variant-branch] [--summary] [--timeout-ms 60000] [--json]');
|
|
17110
17804
|
console.log(' remits-cli components sync doctor [--json]');
|
|
17111
17805
|
console.log('');
|
|
17112
17806
|
console.log('On trunk this reconciles the pushed repository into live component rows.');
|
|
@@ -17216,7 +17910,7 @@ function printComponentsHelp(subcommand) {
|
|
|
17216
17910
|
console.log(' remits-cli components lanes [--base-url URL] [--account-id ID] [--wait-change [--lane-id ID] [--timeout 600]] [--json]');
|
|
17217
17911
|
console.log(' remits-cli components entries --lane-id ID [--base-url URL] [--account-id ID] [--json|--verbose]');
|
|
17218
17912
|
console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
|
|
17219
|
-
console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
|
|
17913
|
+
console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] [--timeout-ms 60000]');
|
|
17220
17914
|
console.log(' remits-cli components commit [--yes] [--safe] [--message|-m "msg"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--json [--summary]]');
|
|
17221
17915
|
console.log('');
|
|
17222
17916
|
console.log(' --force-tombstones applies only on non-trunk variant syncs, when a missing trunk');
|
|
@@ -17457,7 +18151,7 @@ async function main() {
|
|
|
17457
18151
|
// then ran `remits-cli agent serve` from a plain tab (the documented way to work tickets, explicitly
|
|
17458
18152
|
// "no need to be in an account repo") kept the PREVIOUS skill on disk indefinitely: a stale index
|
|
17459
18153
|
// pointing at reference files that the upgrade had just renamed or removed. Reading and comparing the
|
|
17460
|
-
// whole corpus across all
|
|
18154
|
+
// whole corpus across all four agent targets measures ~3ms, so there is nothing to save by guarding it.
|
|
17461
18155
|
try { syncInstalledSkills(); } catch (_) { /* best-effort */ }
|
|
17462
18156
|
|
|
17463
18157
|
// Best-effort: update account-repo index whenever running from inside a remits repo
|
|
@@ -17512,7 +18206,7 @@ async function main() {
|
|
|
17512
18206
|
console.log(' remits-cli guides sync [--all] [--base-url URL] [--json] # refresh front-stage guides');
|
|
17513
18207
|
console.log(' remits-cli listen [stop|status] [--foreground true] # compatibility alias');
|
|
17514
18208
|
console.log(' remits-cli data-mode [set test|prod]');
|
|
17515
|
-
console.log(' remits-cli install --skills [--target codex|claude|gemini|all] [--overwrite true]');
|
|
18209
|
+
console.log(' remits-cli install --skills [--target codex|claude|gemini|antigravity|all] [--overwrite true]');
|
|
17516
18210
|
console.log(' remits-cli tools [--base-url URL] [--account-id ID] [--as-account ID] [--branch BRANCH] [--data-mode test|prod] [--variant-branch NAME|none]');
|
|
17517
18211
|
console.log(' remits-cli tool --name <toolName> [--base-url URL] [--account-id ID] [--as-account ID] [--target-account ID] [--branch BRANCH] [--input \"{...}\"|--input-file file.json] [--data-mode test|prod] [--scope self|children|hierarchy] [--account-ids 1,2,3] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
|
|
17518
18212
|
console.log(' remits-cli tool status --call-id <callId> [--base-url URL] [--account-id ID] [--data-mode test|prod]');
|
|
@@ -17525,7 +18219,7 @@ async function main() {
|
|
|
17525
18219
|
console.log(' remits-cli components lanes [--base-url URL] [--account-id ID] [--json]');
|
|
17526
18220
|
console.log(' remits-cli components entries --lane-id ID [--base-url URL] [--account-id ID] [--json|--verbose]');
|
|
17527
18221
|
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]');
|
|
17528
|
-
console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
|
|
18222
|
+
console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] [--timeout-ms 60000]');
|
|
17529
18223
|
console.log(' remits-cli components commit [--yes] [--safe] [--message|-m \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--json [--summary]]');
|
|
17530
18224
|
console.log(' remits-cli components promotion [<branch>] [--json] [--no-fail] # promotion readiness + ordered next steps');
|
|
17531
18225
|
console.log(' remits-cli components branches [--json] # committed branch variants for this account');
|