@remits/remits-cli 0.1.112 → 0.1.113

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 CHANGED
@@ -21,6 +21,7 @@ remits-cli stop
21
21
  remits-cli install --skills
22
22
  remits-cli tools
23
23
  remits-cli tool --base-url http://localhost:8080 --name "My Tool" --input '{"foo":"bar"}'
24
+ remits-cli tool --name mcp_firestore_search --input '{"collection":"statements","documentId":"1234"}' --scope children
24
25
  remits-cli components stage
25
26
  remits-cli components status
26
27
  remits-cli components clear
@@ -70,7 +71,8 @@ remits-cli install --skills --overwrite true
70
71
  - `components commit` is a convenience wrapper that performs local git commit/push, then `components sync`, then local `git fetch`/`git pull --ff-only`.
71
72
  - `components sync` returns the post-sync branch SHA produced by the platform. `components commit` verifies that `origin/<branch>` and local `HEAD` both match that exact SHA after the final pull.
72
73
  - `components push` is deprecated and currently behaves the same as `components stage`.
73
- - `accountId` resolution for CLI commands: explicit `--account-id` flag wins, then the current repo's `account-info.json` (`resolution.accountId`, then the short-lived `repoContext.accountInfoAccountId`, then the legacy top-level id — legacy files are rooted at the hierarchy ROOT, so their top-level `id` may be an ancestor), then the active session. For `remits-cli tool` calls the server applies a further precedence — explicit `--account-id` > `input.accountId` > session/repo default — so a tool can execute against a different account than the surrounding repo.
74
+ - `accountId` resolution for CLI commands: explicit `--account-id` flag wins, then the current repo's `account-info.json` (`resolution.accountId`, then the short-lived `repoContext.accountInfoAccountId`, then the legacy top-level id — legacy files are rooted at the hierarchy ROOT, so their top-level `id` may be an ancestor), then the active session.
75
+ - For `remits-cli tool`, read/discovery tools treat that resolved account as the **scope root**. You do not need the owning child account id for an exact document/record lookup: pass `--scope children`, or put `scope:"children"` in `--input`, and use the returned `resolvedTargetAccountId` for follow-up writes/runs. Use `--target-account-id` when you already know the exact owner, `--account-ids` for an explicit bounded owner list, and `--anchor-account-id` only to disambiguate multi-parent paths. Mutating tools still require an exact target.
74
76
  - Auth sessions are stored per `accountId + dataMode + baseUrl`, so the same account can stay authenticated against both localhost and production without overwriting the other session.
75
77
  - `--base-url` and `--data-mode` are independent. `--base-url` chooses the Remits host (`http://localhost:8080` vs deployed prod), while `--data-mode` chooses the data segment on that host (`test` vs `prod`). Do not assume `--data-mode prod` means the deployed prod host, or that `--data-mode test` means localhost.
76
78
  - `remits-cli start` scans the machine for `account-info.json` files and rebuilds `~/.remits-cli/account-repos.json` before bringing up the background service.
package/index.js CHANGED
@@ -1248,13 +1248,45 @@ function countAccountRepos(index) {
1248
1248
  return listAccountRepoEntries(index).length;
1249
1249
  }
1250
1250
 
1251
+ /**
1252
+ * Fold discovered checkouts into the index, one entry per account.
1253
+ *
1254
+ * <p><b>Two checkouts can claim one account, and the naive merge picked a silent winner.</b> The index is
1255
+ * keyed by accountId, so a plain last-write-wins loop resolved a fork, a worktree or a dated backup copy
1256
+ * by scan order — which is alphabetical, which is meaningless. Measured on this machine: account 33 had
1257
+ * both `remits-invoice-automation` and `remits-invoice-automation copy-EOD-09302025`, and the index held
1258
+ * the dated copy. Four open tickets would have been worked in a month-old snapshot.</p>
1259
+ *
1260
+ * <p>So: a real checkout always beats a non-checkout (an unpacked export cannot win merely by sorting
1261
+ * later); an explicit `cwd` entry — the operator standing in the directory — always beats a scan; and
1262
+ * when two genuine checkouts remain, the incumbent is KEPT and the loser is recorded in `ambiguousWith`
1263
+ * rather than discarded. Ambiguity a person can see is recoverable; a silent winner is not.</p>
1264
+ */
1251
1265
  function mergeAccountRepoEntries(entries, options = {}) {
1252
1266
  const nextIndex = options.existingIndex ? { ...options.existingIndex } : {};
1267
+ const rank = (entry) => (isAccountCheckout(entry.directory) ? 2 : 0) + (entry.source === 'cwd' ? 1 : 0);
1268
+
1253
1269
  for (const entry of entries) {
1254
1270
  if (!entry || !entry.accountId || !entry.directory) {
1255
1271
  continue;
1256
1272
  }
1257
- nextIndex[String(entry.accountId)] = entry;
1273
+ const key = String(entry.accountId);
1274
+ const current = nextIndex[key];
1275
+ if (!current || !current.directory || current.directory === entry.directory) {
1276
+ nextIndex[key] = { ...entry, ambiguousWith: (current && current.ambiguousWith) || [] };
1277
+ continue;
1278
+ }
1279
+
1280
+ const winner = rank(entry) > rank(current) ? entry : current;
1281
+ const loser = winner === entry ? current : entry;
1282
+ const seen = new Set([...((current.ambiguousWith) || []), ...((entry.ambiguousWith) || [])]);
1283
+ // Only a genuine rival is worth reporting. A non-checkout losing to a checkout is the system
1284
+ // working, not a decision the operator needs to review.
1285
+ if (isAccountCheckout(loser.directory) && isAccountCheckout(winner.directory)) {
1286
+ seen.add(loser.directory);
1287
+ }
1288
+ seen.delete(winner.directory);
1289
+ nextIndex[key] = { ...winner, ambiguousWith: [...seen] };
1258
1290
  }
1259
1291
  writeAccountRepoIndex(nextIndex);
1260
1292
  return nextIndex;
@@ -3756,6 +3788,51 @@ function parseToolInput(flags) {
3756
3788
  return {};
3757
3789
  }
3758
3790
 
3791
+ const TOOL_SCOPES = ['self', 'children', 'hierarchy', 'parents'];
3792
+
3793
+ // Hierarchy-scoped discovery flags, folded into the tool's own input.
3794
+ //
3795
+ // They are CONVENIENCE only: every one of them can be written inside --input, and a value already there
3796
+ // wins, because the explicit JSON is what the caller typed for this specific tool. The flags exist so the
3797
+ // overwhelmingly common shape — "search my repo account's children for this id" — does not require
3798
+ // hand-writing JSON.
3799
+ function applyScopeFlags(input, flags) {
3800
+ const merged = input && typeof input === 'object' ? input : {};
3801
+
3802
+ const scope = flags && (flags.scope || flags['tool-scope']);
3803
+ if (scope && merged.scope === undefined) {
3804
+ const normalized = String(scope).trim().toLowerCase();
3805
+ if (!TOOL_SCOPES.includes(normalized)) {
3806
+ throw new Error('Invalid --scope "' + scope + '". Valid values: ' + TOOL_SCOPES.join(', '));
3807
+ }
3808
+ merged.scope = normalized;
3809
+ }
3810
+
3811
+ const targetAccountId = flags && (flags['target-account-id'] || flags.targetAccountId);
3812
+ if (targetAccountId && merged.targetAccountId === undefined) {
3813
+ merged.targetAccountId = String(targetAccountId).trim();
3814
+ }
3815
+
3816
+ const anchorAccountId = flags && (flags['anchor-account-id'] || flags.anchorAccountId);
3817
+ if (anchorAccountId && merged.anchorAccountId === undefined) {
3818
+ merged.anchorAccountId = String(anchorAccountId).trim();
3819
+ }
3820
+
3821
+ const accountIds = flags && (flags['account-ids'] || flags.accountIds);
3822
+ if (accountIds && merged.accountIds === undefined) {
3823
+ const parsed = String(accountIds)
3824
+ .split(',')
3825
+ .map((part) => Number(String(part).trim()))
3826
+ .filter((value) => Number.isFinite(value) && value > 0);
3827
+ if (!parsed.length) {
3828
+ throw new Error('--account-ids expects a comma-separated list of numeric account ids');
3829
+ }
3830
+ merged.accountIds = parsed;
3831
+ }
3832
+
3833
+ return merged;
3834
+ }
3835
+
3759
3836
  async function toolCommand(flags) {
3760
3837
  const cwd = process.cwd();
3761
3838
  ensureLocalState(cwd);
@@ -3774,7 +3851,7 @@ async function toolCommand(flags) {
3774
3851
  const explicitFlagAccountId = Number(flags && flags['account-id']);
3775
3852
  const accountIdExplicit = Number.isFinite(explicitFlagAccountId) && explicitFlagAccountId > 0;
3776
3853
 
3777
- const input = parseToolInput(flags);
3854
+ const input = applyScopeFlags(parseToolInput(flags), flags);
3778
3855
  const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
3779
3856
  const branchName = flags.branch || currentBranch(cwd);
3780
3857
  const dataMode = resolveDataMode(flags, session);
@@ -3840,6 +3917,10 @@ async function toolCommand(flags) {
3840
3917
  token: session.token,
3841
3918
  accountId,
3842
3919
  accountIdExplicit,
3920
+ // The repo/session account, sent as a FIRST-CLASS value rather than left to be inferred from
3921
+ // `accountId`: a read tool may reinterpret its own `input.accountId`, and the scope root — what bounds
3922
+ // what the call may discover — has to survive that. The server validates it; it is never authorization.
3923
+ scopeRootAccountId: accountId,
3843
3924
  branchName,
3844
3925
  workspace: resolveWorkspace(cwd, flags),
3845
3926
  dataMode,
@@ -6074,28 +6155,55 @@ const AGENT_WORKER_MAX_LOG_BYTES = 8 * 1024 * 1024;
6074
6155
  const AGENT_LAUNCHERS = {
6075
6156
  codex: {
6076
6157
  binary: 'codex',
6158
+ // A ticket worker's whole job runs THROUGH the platform: `remits-cli ticket read/accept/progress/
6159
+ // complete`, and every investigation tool. So the sandbox has to let it reach the platform, and by
6160
+ // default it does not — `workspace-write` disables network, and a worker launched without this flag
6161
+ // fails its FIRST command with exit 6 / HTTP 000 while looking, from the outside, like an agent that
6162
+ // simply did not do much. Passed on the command line rather than documented as a config edit so the
6163
+ // supervisor works on a machine nobody has prepared.
6164
+ //
6165
+ // `read-only` is deliberately NOT used for investigate mode, even though it is the tighter sandbox.
6166
+ // It has no network AND no setting that restores it, so a read-only worker cannot read the ticket it
6167
+ // was given, cannot run a diagnostic, and cannot record a finding — the three things investigating
6168
+ // IS. It would then exit 0 having written nothing, which the supervisor reads as a delivered
6169
+ // judgement and un-routes, so the next sweep hands the same ticket to another worker that learns the
6170
+ // same nothing. A loop that reports success is worse than a refusal.
6171
+ //
6172
+ // What enforces the read-only phase instead: the brief's Boundaries section, which names the lease
6173
+ // holder, says which commands not to run, and explains that the shared CHECKOUT is the thing at
6174
+ // risk. Enforcement moved from the sandbox to the instructions deliberately — the sandbox could not
6175
+ // express "may talk to the platform, may not write this directory".
6077
6176
  build: ({ cwd, mode, lastMessageFile }) => ({
6078
6177
  args: [
6079
6178
  'exec',
6080
6179
  '--cd', cwd,
6081
6180
  '--skip-git-repo-check',
6082
- '--sandbox', mode === 'investigate' ? 'read-only' : 'workspace-write',
6181
+ '--sandbox', 'workspace-write',
6182
+ '-c', 'sandbox_workspace_write.network_access=true',
6083
6183
  '--json',
6084
6184
  ...(lastMessageFile ? ['--output-last-message', lastMessageFile] : []),
6085
6185
  '-'
6086
- ]
6186
+ ],
6187
+ // Reported on the worker record so an operator can tell a phase the platform derived from a
6188
+ // sandbox the launcher applied — they are no longer the same thing for codex.
6189
+ sandbox: 'workspace-write' + (mode === 'investigate' ? ' (investigate: boundary is the brief)' : '')
6087
6190
  })
6088
6191
  },
6089
6192
  claude: {
6090
6193
  binary: 'claude',
6194
+ // Same requirement, different mechanism: in `-p` there is no one to answer a permission prompt, so
6195
+ // an un-allowlisted Bash call is DENIED rather than queued. Without this the worker cannot run
6196
+ // remits-cli at all. `plan` mode is avoided for the same reason `read-only` is avoided above.
6091
6197
  build: ({ cwd, mode }) => ({
6092
6198
  args: [
6093
6199
  '-p',
6094
6200
  '--add-dir', cwd,
6095
6201
  '--output-format', 'stream-json',
6096
6202
  '--verbose',
6097
- '--permission-mode', mode === 'investigate' ? 'plan' : 'acceptEdits'
6098
- ]
6203
+ '--permission-mode', 'acceptEdits',
6204
+ '--allowedTools', 'Bash,Read,Grep,Glob,WebFetch' + (mode === 'investigate' ? '' : ',Edit,Write')
6205
+ ],
6206
+ sandbox: 'acceptEdits' + (mode === 'investigate' ? ' (investigate: no Edit/Write tool)' : '')
6099
6207
  })
6100
6208
  },
6101
6209
  gemini: {
@@ -6107,7 +6215,8 @@ const AGENT_LAUNCHERS = {
6107
6215
  '-p', 'Work the support ticket described above, following its instructions exactly.',
6108
6216
  '--approval-mode', mode === 'investigate' ? 'plan' : 'auto_edit',
6109
6217
  '--output-format', 'stream-json'
6110
- ]
6218
+ ],
6219
+ sandbox: mode === 'investigate' ? 'plan' : 'auto_edit'
6111
6220
  })
6112
6221
  }
6113
6222
  };
@@ -6179,7 +6288,31 @@ function resolveWorkerAgent(value, anchor = null) {
6179
6288
  };
6180
6289
  }
6181
6290
 
6182
- /** Where the repository for an account is checked out on THIS machine, or null. */
6291
+ /**
6292
+ * Is this directory a real checkout, rather than something that merely contains an account-info.json?
6293
+ *
6294
+ * <p>The index is built by scanning for `account-info.json`, and that file is also in every zip export
6295
+ * of an account. An unpacked export in `~/Downloads` therefore looks exactly like a checkout, and it
6296
+ * exists — which was the whole of the previous validation.</p>
6297
+ */
6298
+ function isAccountCheckout(directory) {
6299
+ try {
6300
+ return !!directory && fs.existsSync(path.join(directory, '.git'));
6301
+ } catch (_) {
6302
+ return false;
6303
+ }
6304
+ }
6305
+
6306
+ /**
6307
+ * Where the repository for an account is checked out on THIS machine, or null.
6308
+ *
6309
+ * <p>Null when the entry is not a git checkout, and that is the load-bearing part. The caller's next
6310
+ * step on null is the brief telling the worker "no local checkout was resolved — do not guess", which
6311
+ * is the correct outcome; the previous `fs.existsSync` test let an unpacked zip export in `~/Downloads`
6312
+ * satisfy it instead, so the guard never fired and a worker edited, staged and reported success against
6313
+ * a directory with no remote and no history. Nothing about that run looked wrong. Measured on this
6314
+ * machine: account 11 (11 open tickets) and account 2 both resolved that way.</p>
6315
+ */
6183
6316
  function repoDirectoryForAccount(accountId) {
6184
6317
  const wanted = Number(accountId);
6185
6318
  if (!Number.isFinite(wanted) || wanted <= 0) {
@@ -6187,12 +6320,48 @@ function repoDirectoryForAccount(accountId) {
6187
6320
  }
6188
6321
  const entry = listAccountRepoEntries(readAccountRepoIndex())
6189
6322
  .find((candidate) => Number(candidate.accountId) === wanted);
6190
- if (!entry || !entry.directory || !fs.existsSync(entry.directory)) {
6323
+ if (!entry || !entry.directory || !isAccountCheckout(entry.directory)) {
6191
6324
  return null;
6192
6325
  }
6193
6326
  return entry.directory;
6194
6327
  }
6195
6328
 
6329
+ /**
6330
+ * Everything wrong with the local account-repo index, as sentences an operator can act on.
6331
+ *
6332
+ * <p>Reported at `agent serve` rather than discovered one ticket at a time: a supervisor about to work a
6333
+ * production queue should be told up front which accounts it cannot work, because the alternative is
6334
+ * finding out per ticket, hours apart, from a brief that says only "no checkout resolved".</p>
6335
+ */
6336
+ function accountRepoIndexProblems(ownCwd = null) {
6337
+ const problems = [];
6338
+ // The account this session is standing in resolves from its OWN directory, not from the shared index,
6339
+ // so an ambiguity there is not a problem for this supervisor — it is two worktrees working as intended.
6340
+ // Reporting it anyway trains an operator to ignore the warning, and the warning is load-bearing for
6341
+ // every OTHER account, where a silent wrong winner is unrecoverable.
6342
+ const own = ownCwd ? accountCheckoutIdentity(ownCwd) : null;
6343
+ for (const entry of listAccountRepoEntries(readAccountRepoIndex())) {
6344
+ const label = 'account ' + entry.accountId + ' (' + (entry.name || 'unnamed') + ')';
6345
+ if (!fs.existsSync(entry.directory)) {
6346
+ problems.push({ accountId: Number(entry.accountId), severity: 'unusable',
6347
+ message: label + ': the recorded directory no longer exists — ' + entry.directory });
6348
+ } else if (!isAccountCheckout(entry.directory)) {
6349
+ problems.push({ accountId: Number(entry.accountId), severity: 'unusable',
6350
+ message: label + ': ' + entry.directory + ' is not a git checkout (an unpacked export?). ' +
6351
+ 'Tickets for this account will park instead of being worked.' });
6352
+ }
6353
+ if (Array.isArray(entry.ambiguousWith) && entry.ambiguousWith.length &&
6354
+ !(own && own.accountId === Number(entry.accountId))) {
6355
+ problems.push({ accountId: Number(entry.accountId), severity: 'ambiguous',
6356
+ message: label + ': more than one checkout claims this account. Using ' + entry.directory +
6357
+ '; also found ' + entry.ambiguousWith.join(', ') +
6358
+ '. Set the right one with `remits-cli agent repos --account-id ' + entry.accountId +
6359
+ ' --directory <path>`.' });
6360
+ }
6361
+ }
6362
+ return problems;
6363
+ }
6364
+
6196
6365
  /**
6197
6366
  * The working directory a ticket should be worked in.
6198
6367
  *
@@ -6201,13 +6370,27 @@ function repoDirectoryForAccount(accountId) {
6201
6370
  * neither is checked out here the answer is null, and the brief says so explicitly instead of
6202
6371
  * letting the worker start in whatever directory the supervisor happened to inherit.
6203
6372
  */
6204
- function resolveTicketWorkingDirectory(ticket) {
6373
+ function resolveTicketWorkingDirectory(ticket, ownCwd = null) {
6205
6374
  const facts = (ticket && ticket.briefFacts) || {};
6206
6375
  const candidates = [
6207
6376
  facts.repoAccountId,
6208
6377
  ticket && ticket.implementationAccountId,
6209
6378
  ticket && ticket.accountId
6210
- ];
6379
+ ].map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0);
6380
+
6381
+ // THIS supervisor's own checkout wins, when it is a checkout of one of the candidate accounts.
6382
+ //
6383
+ // The account-repo index is a single global map keyed by accountId, and `agent serve` writes its own
6384
+ // cwd into it — so two supervisors serving two git WORKTREES of one account fight over one entry, and
6385
+ // the second to start silently repoints the first's future workers into its own tree. That is the
6386
+ // shape worktrees exist to prevent: a supervisor must launch its workers where IT is, not where the
6387
+ // machine's last-registered session happened to be. The index stays the answer for every account this
6388
+ // session is not standing in, which is the normal case.
6389
+ const own = ownCwd ? accountCheckoutIdentity(ownCwd) : null;
6390
+ if (own && candidates.includes(own.accountId)) {
6391
+ return own.directory;
6392
+ }
6393
+
6211
6394
  for (const candidate of candidates) {
6212
6395
  const dir = repoDirectoryForAccount(candidate);
6213
6396
  if (dir) {
@@ -6217,6 +6400,30 @@ function resolveTicketWorkingDirectory(ticket) {
6217
6400
  return null;
6218
6401
  }
6219
6402
 
6403
+ /**
6404
+ * Which account this directory is a checkout OF, read from the directory itself.
6405
+ *
6406
+ * Deliberately not a lookup in the shared index: the whole point is to answer for one process without
6407
+ * consulting a map another process can overwrite. Null when the directory is not a real checkout — an
6408
+ * unpacked zip export carries `account-info.json` too, which is exactly the trap `isAccountCheckout`
6409
+ * exists to close.
6410
+ */
6411
+ function accountCheckoutIdentity(directory) {
6412
+ try {
6413
+ if (!directory || !isAccountCheckout(directory)) {
6414
+ return null;
6415
+ }
6416
+ const info = loadAccountInfo(directory);
6417
+ const accountId = Number(info && accountIdFromAccountInfo(info));
6418
+ if (!Number.isFinite(accountId) || accountId <= 0) {
6419
+ return null;
6420
+ }
6421
+ return { accountId, directory: path.resolve(directory) };
6422
+ } catch (_) {
6423
+ return null;
6424
+ }
6425
+ }
6426
+
6220
6427
  /**
6221
6428
  * Turn a worker's streamed output into something an operator can read in one line.
6222
6429
  *
@@ -6665,6 +6872,35 @@ async function agentServeCommand(flags) {
6665
6872
  const mode = String(flags.mode || (flagEnabled(flags['investigate-only']) ? 'investigate' : 'edit'))
6666
6873
  .toLowerCase() === 'investigate' ? 'investigate' : 'edit';
6667
6874
 
6875
+ // Say what this supervisor CANNOT work, before it starts rather than one ticket at a time. A worker
6876
+ // pointed at an account with no usable checkout parks correctly, but the operator only learns that
6877
+ // from a brief they may never read, hours later, on one ticket. `--strict-repos` turns it into a
6878
+ // refusal for anyone who would rather not start a production run with known holes.
6879
+ try {
6880
+ updateAccountRepoIndex(process.cwd());
6881
+ discoverAccountRepos();
6882
+ } catch (_) { /* best-effort; the checks below still report what the index does say */ }
6883
+ const problems = accountRepoIndexProblems(process.cwd());
6884
+ if (problems.length) {
6885
+ console.log('');
6886
+ console.log('Account repository index — ' + problems.length + ' issue(s) to know about before serving:');
6887
+ for (const problem of problems) {
6888
+ console.log(' [' + problem.severity + '] ' + problem.message);
6889
+ }
6890
+ console.log(' Tickets for an "unusable" account are parked with an explanation, never worked in the');
6891
+ console.log(' wrong directory. Fix with: remits-cli agent repos --account-id <id> --directory <path>');
6892
+ const ownHere = accountCheckoutIdentity(process.cwd());
6893
+ if (ownHere) {
6894
+ console.log(' Account ' + ownHere.accountId + ' is exempt: this session serves it from its own directory');
6895
+ console.log(' (' + ownHere.directory + '), so a second worktree elsewhere cannot redirect these workers.');
6896
+ }
6897
+ console.log('');
6898
+ if (flagEnabled(flags['strict-repos'])) {
6899
+ throw new Error('Refusing to serve with ' + problems.length +
6900
+ ' account-repository issue(s). Resolve them, or drop --strict-repos to serve anyway.');
6901
+ }
6902
+ }
6903
+
6668
6904
  return agentRegisterCommand(flags, {
6669
6905
  serve: {
6670
6906
  workerAgent,
@@ -6876,6 +7112,54 @@ function clearWorkerAttempts(agentId, ticketId) {
6876
7112
  }
6877
7113
  }
6878
7114
 
7115
+ /**
7116
+ * The worker's closing summary, recorded on the ticket by the SUPERVISOR.
7117
+ *
7118
+ * <p>A worker is supposed to record what it established itself, with `remits-cli ticket progress`. Most
7119
+ * do. The ones that do not are exactly the runs worth hearing from: a worker that hit its context limit,
7120
+ * that crashed after investigating, or that could not reach the platform at all. Their conclusion was
7121
+ * captured to disk the whole time (`codex exec --output-last-message`) and thrown away, so a ticket came
7122
+ * back to the queue with nothing on it and the next worker rediscovered the same thing.</p>
7123
+ *
7124
+ * <p>Posted only when the run added no worklog entry of its own, so a worker that reported properly is
7125
+ * not duplicated. Marked `worker_summary` rather than `investigation` so a reader can tell a finding the
7126
+ * worker chose to record from a transcript tail the supervisor salvaged.</p>
7127
+ */
7128
+ async function postWorkerSummary(context, agentId, ticket, summaryFile, options = {}) {
7129
+ let text = '';
7130
+ try {
7131
+ text = fs.readFileSync(summaryFile, 'utf8').trim();
7132
+ } catch (_) {
7133
+ return false; // codex only writes it on a clean finish, and the other launchers never do.
7134
+ }
7135
+ if (!text) {
7136
+ return false;
7137
+ }
7138
+ // The worklog is read by people and by the next worker's brief; a whole transcript would bury both.
7139
+ const MAX = 4000;
7140
+ const body = text.length > MAX ? (text.slice(0, MAX) + '\n\n…truncated; full run: ' + options.logFile) : text;
7141
+ try {
7142
+ await buildAxios(context.baseUrl, context.session.token, 20000)
7143
+ .post('/cli/ticket', {
7144
+ action: 'progress',
7145
+ ticketId: ticket.ticketId || ticket.id,
7146
+ accountId: Number(ticket.accountId) || undefined,
7147
+ dataMode: context.dataMode,
7148
+ agentId,
7149
+ category: 'worker_summary',
7150
+ summary: 'Autonomous worker exited without recording a finding; this is its closing summary.',
7151
+ details: body,
7152
+ nextStep: options.nextStep || null
7153
+ });
7154
+ return true;
7155
+ } catch (err) {
7156
+ appendGlobalActivityLog('agent', 'worker.summary_post_failed', {
7157
+ agentId, ticketId: ticket.ticketId || ticket.id, error: describeError(err)
7158
+ }, 'error');
7159
+ return false;
7160
+ }
7161
+ }
7162
+
6879
7163
  /** Drop a claim on the platform. Best-effort: the claim expires on its own if this never lands. */
6880
7164
  async function releaseTicketClaim(context, agentId, ticket, options = {}) {
6881
7165
  try {
@@ -6928,7 +7212,7 @@ function spawnTicketWorker(context, ctx, ticket) {
6928
7212
  return { pid: null, done: Promise.resolve({ ticketId, ok: false, reason: 'unsupported_agent' }) };
6929
7213
  }
6930
7214
 
6931
- const workingDirectory = resolveTicketWorkingDirectory(ticket);
7215
+ const workingDirectory = resolveTicketWorkingDirectory(ticket, ctx && ctx.cwd);
6932
7216
  // The brief is rendered server-side without the local path (the platform cannot know it), so the
6933
7217
  // resolved directory is appended here. When nothing resolved, the server-rendered brief already
6934
7218
  // tells the worker to find it rather than guess — do not paper over that with a default.
@@ -6999,6 +7283,11 @@ function spawnTicketWorker(context, ctx, ticket) {
6999
7283
  workspace: workerWorkspace,
7000
7284
  attempt,
7001
7285
  agentType,
7286
+ // The sandbox the launcher actually applied. Kept beside the phase because for codex they are no
7287
+ // longer the same fact: an investigating worker still runs workspace-write (it needs the network to
7288
+ // report at all), and its boundary is the brief. An operator reading "investigating" must be able to
7289
+ // see that.
7290
+ sandbox: built.sandbox || null,
7002
7291
  startedAt: new Date().toISOString(),
7003
7292
  workingDirectory: workingDirectory || null,
7004
7293
  accountId: ticket.accountId || null,
@@ -7081,6 +7370,22 @@ function spawnTicketWorker(context, ctx, ticket) {
7081
7370
  });
7082
7371
 
7083
7372
  const row = (result && result.ticket) || {};
7373
+
7374
+ // Did this run leave anything on the record? If not, salvage its closing summary before the ticket
7375
+ // goes back to the queue — otherwise the next worker starts from exactly where this one did.
7376
+ // Compared against the count we were HANDED at spawn, so a worker that reported properly (or that
7377
+ // asked a question, or completed) is never duplicated.
7378
+ const worklogBefore = Number(ticket.worklogCount || 0);
7379
+ const worklogAfter = Number(row.worklogCount != null ? row.worklogCount : worklogBefore);
7380
+ if (worklogAfter <= worklogBefore && !['resolved', 'closed'].includes(String(row.status || ''))) {
7381
+ await postWorkerSummary(context, agentId, { ticketId, accountId: ticket.accountId }, lastMessageFile, {
7382
+ logFile,
7383
+ nextStep: giveUp
7384
+ ? 'This worker failed ' + attempt + ' time(s) and the ticket was returned to the queue.'
7385
+ : null
7386
+ });
7387
+ }
7388
+
7084
7389
  const terminal = ['resolved', 'closed'].includes(String(row.status || ''));
7085
7390
  if (terminal || giveUp) {
7086
7391
  clearWorkerAttempts(agentId, ticketId);
@@ -7397,6 +7702,21 @@ async function agentWorkCommand(flags) {
7397
7702
  */
7398
7703
  async function ticketCommand(flags, subcommand) {
7399
7704
  const action = String(subcommand || 'read').toLowerCase();
7705
+
7706
+ // `queue` and `create` are the two verbs with no ticket to act on, so they are dispatched before the
7707
+ // id requirement rather than being forced to invent one.
7708
+ if (action === 'queue' || action === 'list' || action === 'ls') {
7709
+ return ticketQueueCommand(flags);
7710
+ }
7711
+ if (action === 'create' || action === 'new' || action === 'raise') {
7712
+ return ticketCreateCommand(flags);
7713
+ }
7714
+ // Bulk reclaim scans a queue rather than acting on one id, so it is dispatched here too. With
7715
+ // --ticket it falls through to the single-ticket path below like any other verb.
7716
+ if (action === 'reclaim' && !flags.ticket && !flags['ticket-id']) {
7717
+ return ticketReclaimSweepCommand(flags);
7718
+ }
7719
+
7400
7720
  const ticketId = String(flags.ticket || flags['ticket-id'] || flags._?.[2] || process.env.REMITS_SUPPORT_TICKET_ID || '').trim();
7401
7721
  if (!ticketId) {
7402
7722
  printTicketHelp();
@@ -7441,7 +7761,9 @@ async function ticketCommand(flags, subcommand) {
7441
7761
  summary: flags.summary || flags.note,
7442
7762
  question: flags.question,
7443
7763
  context: flags.context,
7444
- to: flags.to,
7764
+ // Recipients are a list on the platform side (message.to / deliver.to). A single value still works,
7765
+ // so `--to a@x.test` and `--to a@x.test,b@x.test` both do what they look like they do.
7766
+ to: flags.to ? String(flags.to).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
7445
7767
  message: flags.message || flags.answer,
7446
7768
  route: flags.route === 'false' ? false : undefined,
7447
7769
  details: flags.details,
@@ -7457,6 +7779,26 @@ async function ticketCommand(flags, subcommand) {
7457
7779
  size: planningFlagValue(flags.size),
7458
7780
  blockedBy: planningFlagValue(firstDefined(flags['blocked-by'], flags.blockedBy)),
7459
7781
  workstream: planningFlagValue(flags.workstream),
7782
+ tags: flags.tags ? String(flags.tags).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
7783
+ label: flags.label,
7784
+ url: flags.url,
7785
+ body: flags.body,
7786
+ role: flags.role,
7787
+ email: flags.email,
7788
+ key: flags.key,
7789
+ value: flags.value,
7790
+ // Conversation and outbox. `--message` is shared with `answer`/`note`, which is deliberate: it is
7791
+ // always "the text", and the ACTION decides who ends up reading it.
7792
+ channel: flags.channel,
7793
+ subject: flags.subject,
7794
+ visibility: flags.visibility,
7795
+ direction: flags.direction,
7796
+ template: flags.template,
7797
+ cc: flags.cc ? String(flags.cc).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
7798
+ deliveryId: flags['delivery-id'] || flags.deliveryId || undefined,
7799
+ idempotencyKey: flags['idempotency-key'] || flags.idempotencyKey || undefined,
7800
+ providerMessageId: flags['provider-message-id'] || undefined,
7801
+ reason: flags.reason,
7460
7802
  // Lease operations act as the AGENT, not the user: the lease is held by a worker session, and a
7461
7803
  // human's email is not a thing that can hold one. Inside a worker these come from the environment
7462
7804
  // the supervisor set, so the commands need no flags.
@@ -7495,7 +7837,7 @@ async function ticketCommand(flags, subcommand) {
7495
7837
  return;
7496
7838
  }
7497
7839
 
7498
- if (flagEnabled(flags.json) || action === 'read') {
7840
+ if (flagEnabled(flags.json) || action === 'read' || action === 'deliveries') {
7499
7841
  console.log(JSON.stringify(response, null, 2));
7500
7842
  return;
7501
7843
  }
@@ -7537,6 +7879,19 @@ async function ticketCommand(flags, subcommand) {
7537
7879
  const ticket = (response && response.ticket) || {};
7538
7880
  console.log(action + ' ok — ticket ' + ticketId + ' is now [' + (ticket.status || '?') + ']' +
7539
7881
  (ticket.assignedTo ? ', owned by ' + ticket.assignedTo : ', unassigned'));
7882
+ // Say what was actually written. Without this, `message`, `note`, `deliver` and `participant` all
7883
+ // printed the same status line, so the only feedback distinguishing "told the customer" from "left an
7884
+ // internal note" was the word the caller had just typed.
7885
+ if (action === 'message') {
7886
+ console.log(' public message added (' + (ticket.messageCount || '?') + ' on the ticket) — visible to the requester.');
7887
+ console.log(' This did NOT send anything. To have it delivered: remits-cli ticket deliver --channel email --to <who>');
7888
+ }
7889
+ if (action === 'note') console.log(' internal note added — the requester does not see it.');
7890
+ if (action === 'participant') console.log(' participants: ' + (ticket.participantCount || '?'));
7891
+ if (action === 'deliver') {
7892
+ console.log(' delivery queued (' + (ticket.deliveryCount || '?') + ' on the ticket). An account-owned rule or');
7893
+ console.log(' scheduled Action sends it. Check with: remits-cli ticket deliveries --ticket ' + ticketId);
7894
+ }
7540
7895
  if (action === 'planning' || action === 'plan') {
7541
7896
  const planning = [
7542
7897
  ticket.workstream ? 'workstream=' + ticket.workstream : '',
@@ -7557,6 +7912,229 @@ async function ticketCommand(flags, subcommand) {
7557
7912
  }
7558
7913
  }
7559
7914
 
7915
+ /**
7916
+ * The ticket queue — what else is open, and what it looks like as a whole.
7917
+ *
7918
+ * <p><b>Why this had to exist.</b> The worker brief is emphatic that `remits-cli ticket` is the surface to
7919
+ * use, and that an account's own ticket tooling must not be reached for: it belongs to one account, may not
7920
+ * exist on this platform, and refuses tickets other accounts own. That advice was right and the surface was
7921
+ * incomplete — it could act on ONE ticket whose id it was handed, and could not see anything else. So a
7922
+ * worker could not tell that the ticket it was given is one of eleven instances of the same fix, could not
7923
+ * check whether the defect it just found is already filed, and could not triage at all. The server side
7924
+ * already existed in full (`/cli/tickets`, the same `queue(...)` the operator UI reads); its only consumer
7925
+ * was the local dashboard.</p>
7926
+ */
7927
+ async function ticketQueueCommand(flags) {
7928
+ const local = listLocalAgents()[0] || null;
7929
+ const context = agentSessionFor(flags, local);
7930
+ const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
7931
+ if (!Number.isFinite(accountId) || accountId <= 0) {
7932
+ throw new Error('ticket queue needs --account-id (or run it from an account repo).');
7933
+ }
7934
+
7935
+ const csv = (value) => (value ? String(value).split(',').map((v) => v.trim()).filter(Boolean) : undefined);
7936
+ const response = await buildAxios(context.baseUrl, context.session.token, 30000)
7937
+ .post('/cli/tickets', {
7938
+ accountId,
7939
+ dataMode: context.dataMode,
7940
+ statuses: csv(flags.status || flags.statuses),
7941
+ priorities: csv(flags.priority || flags.priorities),
7942
+ types: csv(flags.type || flags.types),
7943
+ sources: csv(flags.source || flags.sources),
7944
+ workstream: flags.workstream,
7945
+ assignedTo: flags['assigned-to'] || flags.assignedTo,
7946
+ routedAgentId: flags['routed-to'] || flags.routedAgentId,
7947
+ unrouted: flagEnabled(flags.unrouted) || undefined,
7948
+ unassigned: flagEnabled(flags.unassigned) || undefined,
7949
+ // "Parked on a human" cannot be asked for with a status filter: `ask` parks a ticket in
7950
+ // pending_review, which also means "done, review me".
7951
+ awaitingResponse: flagEnabled(flags['awaiting-response'] || flags.awaitingResponse) || undefined,
7952
+ plannedIn: flags['planned-in'] || flags.plannedIn,
7953
+ boardStage: flags['board-stage'] || flags.boardStage,
7954
+ blockedBy: flags['blocked-by'] || flags.blockedBy,
7955
+ search: flags.search || flags.q,
7956
+ sortBy: flags['sort-by'] || flags.sortBy,
7957
+ sortDirection: flags['sort-direction'] || flags.sortDirection,
7958
+ includeChildren: flags['include-children'] === 'false' ? false : undefined,
7959
+ limit: parsePositiveInt(flags.limit, 25)
7960
+ }).then((r) => r.data);
7961
+
7962
+ if (flagEnabled(flags.json)) {
7963
+ console.log(JSON.stringify(response, null, 2));
7964
+ return;
7965
+ }
7966
+
7967
+ const tickets = (response && response.tickets) || [];
7968
+ const counts = (response && response.counts) || {};
7969
+ if (!tickets.length) {
7970
+ console.log('No tickets match. (' + (response && response.dataMode) + ' lane, account ' + accountId + ')');
7971
+ return;
7972
+ }
7973
+ console.log(String(response.totalCount != null ? response.totalCount : tickets.length) +
7974
+ ' ticket(s) — ' + (counts.unrouted || 0) + ' unrouted, ' + (counts.unassigned || 0) + ' unassigned' +
7975
+ ', ' + (counts.awaitingResponse || 0) + ' waiting on a human' +
7976
+ ' [' + (response.dataMode || '?') + ' lane]');
7977
+ console.log('');
7978
+ // Board position is printed only when some ticket in this queue actually has one. An always-on column
7979
+ // would be blank for every account that does not run a board, and a blank column reads as missing data.
7980
+ const showsBoard = tickets.some((t) => t.boardStage || t.plannedIn || t.blockedBy);
7981
+ for (const t of tickets) {
7982
+ // The repo account, not just the owning account: it is what the edit lease excludes on, so it is
7983
+ // what tells a reader which of these can be worked at the same time.
7984
+ const repo = t.implementationAccountId || t.accountId;
7985
+ const who = t.routedAgentLabel || t.routedAgentId || t.assignedTo || '—';
7986
+ console.log(' ' + String(t.ticketId || t.id).padStart(6) + ' ' +
7987
+ String(t.status || '').padEnd(14) + String(t.priority || '').padEnd(9) +
7988
+ 'repo ' + String(repo || '?').padEnd(6) +
7989
+ String(who).slice(0, 18).padEnd(20) + String(t.subject || '').slice(0, 58));
7990
+ // A second line rather than more columns: these are the account's own labels, of unbounded length,
7991
+ // and truncating `awaiting_customer_confirmation` to fit a column makes it unrecognisable.
7992
+ const marks = [
7993
+ t.awaitingResponseSince ? 'WAITING ON A HUMAN' : '',
7994
+ showsBoard && t.boardStage ? 'stage ' + t.boardStage : '',
7995
+ showsBoard && t.plannedIn ? 'in ' + t.plannedIn : '',
7996
+ showsBoard && t.blockedBy ? 'BLOCKED BY ' + t.blockedBy : ''
7997
+ ].filter(Boolean);
7998
+ if (marks.length) console.log(' '.repeat(10) + marks.join(' '));
7999
+ }
8000
+ if (response.totalCount > tickets.length) {
8001
+ console.log('');
8002
+ console.log(' showing ' + tickets.length + ' of ' + response.totalCount + ' — raise --limit to see more');
8003
+ }
8004
+ }
8005
+
8006
+ /**
8007
+ * Find tickets an agent took and abandoned, and hand them back to the queue.
8008
+ *
8009
+ * <p><b>Dry by default.</b> This takes work away from a holder on a judgement about staleness, across a
8010
+ * whole account tree, in one command — so the default is to show what it WOULD do. `--apply` is the
8011
+ * operator saying yes. The platform re-checks every row against the document before acting, so a stale
8012
+ * listing cannot cause a wrong reclaim even if this list is minutes old.</p>
8013
+ */
8014
+ async function ticketReclaimSweepCommand(flags) {
8015
+ const local = listLocalAgents()[0] || null;
8016
+ const context = agentSessionFor(flags, local);
8017
+ const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
8018
+ if (!Number.isFinite(accountId) || accountId <= 0) {
8019
+ throw new Error('ticket reclaim needs --account-id (or --ticket ID for a single ticket).');
8020
+ }
8021
+ const staleHours = parsePositiveInt(flags['stale-hours'] || flags.staleHours, 24);
8022
+ const apply = flagEnabled(flags.apply);
8023
+
8024
+ const queue = await buildAxios(context.baseUrl, context.session.token, 30000)
8025
+ .post('/cli/tickets', {
8026
+ accountId, dataMode: context.dataMode,
8027
+ statuses: ['accepted', 'in_progress'], limit: parsePositiveInt(flags.limit, 200)
8028
+ }).then((r) => r.data);
8029
+
8030
+ const cutoff = Date.now() - staleHours * 3600000;
8031
+ // A local pre-filter only, to keep the output short and avoid asking about obviously-live rows. The
8032
+ // platform owns the actual decision and will refuse anything that does not qualify.
8033
+ const candidates = ((queue && queue.tickets) || []).filter((t) => {
8034
+ const holder = t.assignedTo || t.routedAgentId;
8035
+ if (!holder || String(holder).includes('@')) return false;
8036
+ if (t.awaitingResponseSince) return false;
8037
+ const touched = Date.parse(t.updatedAt || t.claimedAt || t.assignedAt || '');
8038
+ return Number.isFinite(touched) && touched < cutoff;
8039
+ });
8040
+
8041
+ if (!candidates.length) {
8042
+ console.log('No abandoned tickets under account ' + accountId + ' (agent-held, untouched for ' +
8043
+ staleHours + 'h). ' + (((queue && queue.tickets) || []).length) + ' accepted/in_progress ticket(s) checked.');
8044
+ return;
8045
+ }
8046
+
8047
+ console.log((apply ? 'Reclaiming ' : 'Would reclaim ') + candidates.length + ' abandoned ticket(s) — ' +
8048
+ 'agent-held, no live session, untouched for ' + staleHours + 'h:');
8049
+ console.log('');
8050
+ let done = 0;
8051
+ for (const t of candidates) {
8052
+ const id = String(t.ticketId || t.id);
8053
+ const line = ' ' + id.padStart(6) + ' ' + String(t.status || '').padEnd(13) +
8054
+ String(t.assignedTo || t.routedAgentId || '').slice(0, 16).padEnd(18) +
8055
+ String(t.subject || '').slice(0, 52);
8056
+ if (!apply) {
8057
+ console.log(line);
8058
+ continue;
8059
+ }
8060
+ try {
8061
+ await buildAxios(context.baseUrl, context.session.token, 30000)
8062
+ .post('/cli/ticket', {
8063
+ action: 'reclaim', ticketId: id, accountId: Number(t.accountId) || undefined,
8064
+ dataMode: context.dataMode, staleHours, reason: flags.reason || undefined
8065
+ });
8066
+ done += 1;
8067
+ console.log(line + ' -> open');
8068
+ } catch (err) {
8069
+ const body = err && err.response && err.response.data;
8070
+ // A refusal is the platform re-checking against the document and disagreeing with this listing.
8071
+ // That is the guard working, so it is reported per ticket rather than failing the run.
8072
+ console.log(line + ' -> refused: ' + ((body && body.message) || describeError(err)).slice(0, 90));
8073
+ }
8074
+ }
8075
+ console.log('');
8076
+ console.log(apply
8077
+ ? (done + ' reclaimed and back in the queue; an idle supervisor will sweep them.')
8078
+ : 'Dry run. Re-run with --apply to reclaim these.');
8079
+ }
8080
+
8081
+ /**
8082
+ * Raise a ticket.
8083
+ *
8084
+ * <p>The verb a worker needs when it finds a SECOND problem. Without it the only options were to widen the
8085
+ * ticket it was given until the record no longer described one piece of work, or to drop the finding.</p>
8086
+ */
8087
+ async function ticketCreateCommand(flags) {
8088
+ const local = listLocalAgents()[0] || null;
8089
+ const context = agentSessionFor(flags, local);
8090
+ const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
8091
+ if (!Number.isFinite(accountId) || accountId <= 0) {
8092
+ throw new Error('ticket create needs --account-id (or run it from an account repo).');
8093
+ }
8094
+ if (!flags.subject) {
8095
+ throw new Error('ticket create needs --subject "..." and --type defect|question|task|incident|enhancement.');
8096
+ }
8097
+
8098
+ let response;
8099
+ try {
8100
+ response = await buildAxios(context.baseUrl, context.session.token, 30000)
8101
+ .post('/cli/ticketCreate', {
8102
+ accountId,
8103
+ dataMode: context.dataMode,
8104
+ subject: flags.subject,
8105
+ description: flags.description || flags.body,
8106
+ type: flags.type || 'defect',
8107
+ priority: flags.priority,
8108
+ source: flags.source,
8109
+ workstream: flags.workstream,
8110
+ affectedComponent: flags['affected-component'] || flags.affectedComponent,
8111
+ implementationAccountId: flags['implementation-account-id'] || flags.implementationAccountId,
8112
+ // A deterministic key makes this get-or-create, which is what stops a swept fleet raising the
8113
+ // same follow-up once per worker.
8114
+ referenceId: flags['reference-id'] || flags.referenceId,
8115
+ tags: flags.tags ? String(flags.tags).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
8116
+ agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined
8117
+ }).then((r) => r.data);
8118
+ } catch (err) {
8119
+ const body = err && err.response && err.response.data;
8120
+ if (body && body.message) {
8121
+ console.error('Refused: ' + body.message);
8122
+ process.exitCode = 1;
8123
+ return;
8124
+ }
8125
+ throw err;
8126
+ }
8127
+
8128
+ if (flagEnabled(flags.json)) {
8129
+ console.log(JSON.stringify(response, null, 2));
8130
+ return;
8131
+ }
8132
+ const t = (response && response.ticket) || {};
8133
+ console.log('created ticket ' + response.ticketId + ' on account ' + response.accountId +
8134
+ ' [' + (t.status || 'open') + ']');
8135
+ console.log(' ' + (t.subject || flags.subject));
8136
+ }
8137
+
7560
8138
  /** The account a ticket belongs to, so routing does not make the caller look it up by hand. */
7561
8139
  async function resolveTicketAccountId(context, ticketId) {
7562
8140
  const read = await buildAxios(context.baseUrl, context.session.token, 20000)
@@ -7573,6 +8151,20 @@ async function resolveTicketAccountId(context, ticketId) {
7573
8151
  function printTicketHelp() {
7574
8152
  console.log('Usage: remits-cli ticket <action> --ticket ID');
7575
8153
  console.log('');
8154
+ console.log(' queue [--account-id ID] [--status open,accepted] [--unrouted] [--search "..."]');
8155
+ console.log(' What else is open. Read it BEFORE deciding your ticket is unique — the same fix often');
8156
+ console.log(' appears a dozen times, and the "repo" column is the account the edit lease excludes');
8157
+ console.log(' on, so it also tells you which of these can be worked at the same time.');
8158
+ console.log(' Also: --priority --type --workstream --assigned-to --unassigned --board-stage');
8159
+ console.log(' --planned-in --blocked-by --sort-by --sort-direction --limit --json');
8160
+ console.log(' --awaiting-response lists only tickets parked on a human. A status filter cannot');
8161
+ console.log(' ask that: `ask` parks a ticket in pending_review, which also means "done, review me".');
8162
+ console.log(' create --subject "..." --type defect|question|task|incident|enhancement');
8163
+ console.log(' Raise a ticket. Use it when you find a SECOND problem while working one — file it');
8164
+ console.log(' rather than widening the ticket you were given. [--priority --description --tags');
8165
+ console.log(' --workstream --affected-component --implementation-account-id --reference-id]');
8166
+ console.log(' --reference-id makes it get-or-create, so a re-run does not duplicate it.');
8167
+ console.log('');
7576
8168
  console.log(' read The full ticket record, as JSON.');
7577
8169
  console.log(' where WHERE this work is happening: repository account,');
7578
8170
  console.log(' checkout, branch, staging workspace lane, the live edit lease and who holds it, the');
@@ -7584,13 +8176,39 @@ function printTicketHelp() {
7584
8176
  console.log(' complete --resolution "what you found and did"');
7585
8177
  console.log(' ask --question "..." [--context "..."] [--to WHO]');
7586
8178
  console.log(' Put a decision to a human and park. Use it when the CHOICE is not yours.');
7587
- console.log(' reply --message "..." [--route false]');
7588
- console.log(' Answer the open question. By default this re-routes the ticket, so a fresh worker');
7589
- console.log(' resumes with your answer already in its brief.');
8179
+ console.log(' answer --message "..." [--route false] (alias: reply)');
8180
+ console.log(' Answer the open question a previous run asked. By default this re-routes the ticket,');
8181
+ console.log(' so a fresh worker resumes with your answer already in its brief. This is NOT how you');
8182
+ console.log(' talk to the requester — that is `message`. (`reply` means the conversational one in');
8183
+ console.log(' some account tools, so this surface teaches `answer` and keeps `reply` as an alias.)');
8184
+ console.log(' message --message "..." [--to a@x,b@y] [--subject "..."] [--channel email]');
8185
+ console.log(' A PUBLIC conversation entry the requester reads. Writing it does not send it:');
8186
+ console.log(' the record is vendor-agnostic and the account\'s own rule chooses the transport.');
8187
+ console.log(' deliver --channel email --to a@x [--template T] [--subject "..."] [--idempotency-key K]');
8188
+ console.log(' Ask for that entry to actually be SENT. It records an outbox request; an account-owned');
8189
+ console.log(' rule or scheduled Action delivers it and marks the result. Use it when a ticket is');
8190
+ console.log(' waiting on somebody who is not watching the queue.');
8191
+ console.log(' deliveries [--channel email] What is queued to go out, and what already went.');
8192
+ console.log(' delivered --delivery-id ID | delivery-failed --delivery-id ID --reason "..."');
7590
8193
  console.log(' release [--notes "..."] Hand it back to the queue.');
8194
+ console.log(' reclaim [--ticket ID | --account-id ID] [--stale-hours 24] [--apply]');
8195
+ console.log(' OPERATOR: take a ticket back from an agent that accepted it and vanished. Such a');
8196
+ console.log(' ticket is invisible to the sweep (it is assigned, so nothing offers it to anyone).');
8197
+ console.log(' Never touches a ticket owned by a person, holding a live claim, or awaiting a reply.');
8198
+ console.log(' Without --ticket it scans the account tree and is a DRY RUN until --apply.');
7591
8199
  console.log(' reopen | assign --assignee EMAIL');
7592
8200
  console.log(' route --agent AGENT_ID Hand it to a registered agent session.');
7593
8201
  console.log(' unroute Clear its routing, e.g. when that session is gone.');
8202
+ console.log(' tag --tags a,b Mark it. The cheapest way to make a cluster of');
8203
+ console.log(' near-identical tickets visible as one cluster to whoever looks next.');
8204
+ console.log(' artifact --type TYPE --label "..." [--url U | --content "..."]');
8205
+ console.log(' Attach evidence — a log, a query result, a repro. Better than pasting it into a');
8206
+ console.log(' worklog summary, where it is neither typed nor retrievable.');
8207
+ console.log(' note --message "..." An INTERNAL note. The next worker and a reviewer');
8208
+ console.log(' see it; the requester does not. Use `message` for anything they should read.');
8209
+ console.log(' participant --email E [--role watcher|requester|agent]');
8210
+ console.log(' field --key K --value V Write the ORGANIZATION\'s own field on the ticket,');
8211
+ console.log(' outside the platform\'s bounded planning slots. Whatever your account models.');
7594
8212
  console.log(' planning [--workstream V] [--planned-in V] [--board-stage V] [--rank N] [--size V] [--blocked-by V]');
7595
8213
  console.log(' Update organization-owned planning fields without changing platform lifecycle status.');
7596
8214
  console.log(' Pass a flag with no value to CLEAR that slot, e.g. --blocked-by');
@@ -7773,6 +8391,104 @@ async function agentListCommand(flags) {
7773
8391
  }
7774
8392
  }
7775
8393
 
8394
+ /**
8395
+ * Inspect and correct the local account-repository index — which checkout a ticket for an account is
8396
+ * worked in.
8397
+ *
8398
+ * <p>This index is the one fact the platform cannot supply: it knows the ticket and the account, and
8399
+ * only this machine knows where that account is checked out. It is built by scanning for
8400
+ * `account-info.json`, which is also present in every unpacked zip export, so it can and does acquire
8401
+ * entries that are not checkouts at all. There was no way to see it, and no way to correct it.</p>
8402
+ */
8403
+ async function agentReposCommand(flags) {
8404
+ const accountId = Number(flags['account-id'] || flags.accountId);
8405
+ const directory = flags.directory || flags.dir;
8406
+
8407
+ if (Number.isFinite(accountId) && accountId > 0 && directory) {
8408
+ const resolved = path.resolve(String(directory));
8409
+ if (!fs.existsSync(resolved)) {
8410
+ throw new Error('No such directory: ' + resolved);
8411
+ }
8412
+ if (!isAccountCheckout(resolved)) {
8413
+ throw new Error(resolved + ' is not a git checkout. A ticket worker edits, stages and reports ' +
8414
+ 'against this directory, so an unpacked export would silently lose the work.');
8415
+ }
8416
+ const info = loadAccountInfo(resolved);
8417
+ const declared = info ? Number(accountIdFromAccountInfo(info)) : null;
8418
+ if (declared && declared !== accountId) {
8419
+ throw new Error(resolved + ' declares account ' + declared + ', not ' + accountId +
8420
+ '. Point account ' + declared + ' at it instead, or pass the directory that belongs to ' + accountId + '.');
8421
+ }
8422
+ const index = readAccountRepoIndex();
8423
+ const previous = index[String(accountId)];
8424
+ index[String(accountId)] = {
8425
+ ...(previous || {}),
8426
+ ...(info ? buildAccountRepoEntryFromInfo(info, resolved, 'manual') : {}),
8427
+ accountId,
8428
+ directory: resolved,
8429
+ source: 'manual',
8430
+ // The operator has just answered the question, so the rival is no longer ambiguity.
8431
+ ambiguousWith: [],
8432
+ updatedAt: new Date().toISOString()
8433
+ };
8434
+ writeAccountRepoIndex(index);
8435
+ console.log('account ' + accountId + ' -> ' + resolved);
8436
+ if (previous && previous.directory && previous.directory !== resolved) {
8437
+ console.log(' (was ' + previous.directory + ')');
8438
+ }
8439
+ return;
8440
+ }
8441
+
8442
+ if (Number.isFinite(accountId) && accountId > 0 && flagEnabled(flags.forget)) {
8443
+ const index = readAccountRepoIndex();
8444
+ const previous = index[String(accountId)];
8445
+ delete index[String(accountId)];
8446
+ writeAccountRepoIndex(index);
8447
+ console.log(previous
8448
+ ? 'account ' + accountId + ' removed (was ' + previous.directory + '). Its tickets will now park ' +
8449
+ 'with an explanation rather than being worked in that directory.'
8450
+ : 'account ' + accountId + ' was not in the index.');
8451
+ return;
8452
+ }
8453
+
8454
+ if (flagEnabled(flags.rescan)) {
8455
+ try { discoverAccountRepos(); } catch (err) { console.error('scan failed: ' + describeError(err)); }
8456
+ }
8457
+
8458
+ const entries = listAccountRepoEntries(readAccountRepoIndex())
8459
+ .sort((a, b) => Number(a.accountId) - Number(b.accountId));
8460
+ if (flagEnabled(flags.json)) {
8461
+ console.log(JSON.stringify({ repos: entries, problems: accountRepoIndexProblems() }, null, 2));
8462
+ return;
8463
+ }
8464
+
8465
+ console.log('Account repositories on this machine (' + entries.length + '):');
8466
+ console.log('');
8467
+ for (const entry of entries) {
8468
+ const ok = isAccountCheckout(entry.directory);
8469
+ const missing = !fs.existsSync(entry.directory);
8470
+ const state = missing ? 'MISSING ' : (ok ? 'ok ' : 'NOT-GIT ');
8471
+ console.log(' ' + state + String(entry.accountId).padStart(5) + ' ' +
8472
+ String(entry.name || '').slice(0, 26).padEnd(26) + ' ' + entry.directory);
8473
+ for (const rival of entry.ambiguousWith || []) {
8474
+ console.log(' also claims this account: ' + rival);
8475
+ }
8476
+ }
8477
+
8478
+ const problems = accountRepoIndexProblems();
8479
+ if (problems.length) {
8480
+ console.log('');
8481
+ console.log(problems.length + ' issue(s):');
8482
+ for (const problem of problems) {
8483
+ console.log(' [' + problem.severity + '] ' + problem.message);
8484
+ }
8485
+ }
8486
+ console.log('');
8487
+ console.log(' Set one: remits-cli agent repos --account-id <id> --directory <path>');
8488
+ console.log(' Remove one: remits-cli agent repos --account-id <id> --forget');
8489
+ console.log(' Rescan: remits-cli agent repos --rescan');
8490
+ }
8491
+
7776
8492
  async function agentReleaseCommand(flags) {
7777
8493
  const agentId = resolveAgentId(flags, { optional: true });
7778
8494
  if (!agentId) {
@@ -7813,6 +8529,7 @@ async function agentCommand(flags, subcommand) {
7813
8529
  case 'work': return agentWorkCommand(flags);
7814
8530
  case 'list': case 'ls': return agentListCommand(flags);
7815
8531
  case 'map': case 'work-map': case 'workmap': return agentWorkMapCommand(flags);
8532
+ case 'repos': case 'repo': return agentReposCommand(flags);
7816
8533
  case 'release': case 'deregister': case 'stop': return agentReleaseCommand(flags);
7817
8534
  case 'heartbeat-daemon': return agentHeartbeatDaemonCommand(flags);
7818
8535
  default:
@@ -7858,6 +8575,12 @@ function printAgentHelp() {
7858
8575
  console.log(' map [--account-ids 1,4] [--json] Who is EDITING which repository right now.');
7859
8576
  console.log(' One agent at a time may edit an account\'s repo (the edit lease); everyone else');
7860
8577
  console.log(' investigates read-only. Check this before starting work that changes files.');
8578
+ console.log(' repos [--rescan] [--json] WHERE each account is checked out on this machine —');
8579
+ console.log(' the one fact the platform cannot supply, and what decides the directory a ticket');
8580
+ console.log(' worker is launched into. Flags anything that is not a git checkout (an unpacked');
8581
+ console.log(' export looks identical to the scanner) and any account claimed by two checkouts.');
8582
+ console.log(' Correct it with --account-id <id> --directory <path>, or --account-id <id> --forget');
8583
+ console.log(' to make that account\'s tickets park instead of being worked somewhere wrong.');
7861
8584
  console.log(' release Deregister this session immediately.');
7862
8585
  console.log('');
7863
8586
  console.log(' After `register`, the other subcommands need no flags: they reuse the platform, lane');
@@ -8366,8 +9089,16 @@ function printToolHelp() {
8366
9089
  console.log('Usage: remits-cli tool --name <toolName> [--base-url URL] [--account-id ID] [--branch BRANCH] [--input "{...}"|--input-file file.json] [--data-mode test|prod] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
8367
9090
  console.log(' remits-cli tool status --call-id <callId> [--base-url URL] [--account-id ID] [--data-mode test|prod]');
8368
9091
  console.log('');
9092
+ console.log('Hierarchy scope (read/discovery tools) — you do NOT need the owning child account id:');
9093
+ console.log(' --scope self|children|hierarchy expand from the repo account when searching');
9094
+ console.log(' --target-account-id ID the exact account to act on (required by writes)');
9095
+ console.log(' --account-ids 1,2,3 an explicit bounded list of owner accounts');
9096
+ console.log(' --anchor-account-id ID disambiguate the path for a multi-parent account');
9097
+ console.log(' These are merged into --input; a value already in --input wins.');
9098
+ console.log('');
8369
9099
  console.log('Examples:');
8370
9100
  console.log(' remits-cli tool --name mcp_firestore_search --input-file query.json --data-mode prod');
9101
+ console.log(' remits-cli tool --name mcp_firestore_search --input \'{"collection":"statements","documentId":"1234"}\' --scope children');
8371
9102
  console.log(' remits-cli tool --name mcp_run_action --input \'{"accountId":49,"actionId":200,"executionMode":"async"}\' --data-mode prod');
8372
9103
  console.log('');
8373
9104
  console.log('For mcp_run_action and mcp_run_agent, prefer the tool\'s own executionMode:"async" and poll with that tool\'s controlAction:"status".');
@@ -8454,7 +9185,7 @@ async function main() {
8454
9185
  console.log(' remits-cli data-mode [set test|prod]');
8455
9186
  console.log(' remits-cli install --skills [--target codex|claude|gemini|all] [--overwrite true]');
8456
9187
  console.log(' remits-cli tools [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--variant-branch NAME|none]');
8457
- console.log(' remits-cli tool --name <toolName> [--base-url URL] [--branch BRANCH] [--input \"{...}\"|--input-file file.json] [--data-mode test|prod] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
9188
+ 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]');
8458
9189
  console.log(' remits-cli tool status --call-id <callId> [--base-url URL] [--account-id ID] [--data-mode test|prod]');
8459
9190
  console.log(' remits-cli workspace [show|use <name>|use --auto|clear] # isolate staging when several agents share a repo');
8460
9191
  console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--changed-only] [--data-mode test|prod] [--json|--verbose]');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remits/remits-cli",
3
- "version": "0.1.112",
3
+ "version": "0.1.113",
4
4
  "description": "Local CLI for auth, component sync, and live test execution against Remits",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -23,6 +23,10 @@ description: Use remits-cli for fast branch-scoped component staging, test execu
23
23
  - [You are an agent, and you register yourself](#you-are-an-agent-and-you-register-yourself)
24
24
  - [Autonomous: one ticket, one process](#autonomous-one-ticket-one-process)
25
25
  - [If you are the worker](#if-you-are-the-worker)
26
+ - [Before you edit anything: where you are, and whether you may](#before-you-edit-anything-where-you-are-and-whether-you-may)
27
+ - [Your account's process is binding, and it is already in your brief](#your-accounts-process-is-binding-and-it-is-already-in-your-brief)
28
+ - [Seeing the queue as a human does](#seeing-the-queue-as-a-human-does)
29
+ - [Agent components are workers too](#agent-components-are-workers-too)
26
30
  - [Moving a ticket through its lifecycle](#moving-a-ticket-through-its-lifecycle)
27
31
  - [When a worker needs a decision from a human](#when-a-worker-needs-a-decision-from-a-human)
28
32
  - [The manual loop](#the-manual-loop)
@@ -587,6 +591,64 @@ headless run is invisible otherwise), and **end in a terminal state** — `compl
587
591
  resolution, or `update_status` with what you established and what the next agent should try. Exiting
588
592
  quietly leaves a ticket that looks in-flight forever.
589
593
 
594
+ ### Before you edit anything: where you are, and whether you may
595
+
596
+ Presence answers *who*. Two more facts answer *whether you may edit*, and with git worktrees and
597
+ workspace lanes an account id no longer identifies a working tree — so an agent that does not ask these
598
+ is assuming, and the assumption it makes when it guesses wrong is "this is my repository to edit".
599
+
600
+ ```bash
601
+ remits-cli ticket where --ticket 22454 # repo account, checkout, branch, staging lane, lease, claim, YOUR phase
602
+ remits-cli agent map # every repository, who holds each lease, and where each agent is working
603
+ ```
604
+
605
+ **Reading, reproducing and investigating are parallel-safe and unrestricted. Editing one account's
606
+ repository is exclusive**, enforced by a lease held per repository account per data lane:
607
+
608
+ ```bash
609
+ remits-cli ticket lease --ticket 22454 # take it when you started read-only and reached an actual edit
610
+ remits-cli ticket unlease --ticket 22454 # complete / ask / release already do this for you
611
+ ```
612
+
613
+ Four things are worth knowing and are not obvious:
614
+
615
+ - **A refusal is not an error.** The run continues read-only, and the message names who holds the lease
616
+ **and where they are working**, so you can tell a real conflict from a holder in a different worktree.
617
+ **Do not wait for a lease and do not poll for one** — investigation is most of the work on most
618
+ tickets, and a ticket that turns out to need an edit hands off with `ticket progress --next-step`
619
+ saying exactly what change is needed. A lock people queue on turns one stuck worker into a stalled
620
+ fleet.
621
+ - **A staging workspace does not replace the lease.** Separate lanes stop two runs *resolving* each
622
+ other's staged code; they do nothing about two processes writing the same files or pushing the same
623
+ branch, which is what actually destroys work.
624
+ - **One editing worker per repository account per lane — worktrees do not change this.** Two worktrees
625
+ of one repo push to the same branch on the same remote, so per-directory leases would trade file
626
+ conflicts for non-fast-forward push conflicts, which surface later and are worse.
627
+ - **A spawned ticket worker already has its own staging lane** (`REMITS_WORKSPACE=ticket-<id>`). You do
628
+ not set it, and your brief states it. Say which lane you staged into when you report what you verified
629
+ — somebody looking at the shared lane will not see your changes.
630
+
631
+ `unlease` returns **your own** lease and deliberately cannot touch anybody else's. Breaking a stale one
632
+ is a separate, human verb: `remits-cli ticket force-unlease --ticket ID --reason "..."`.
633
+
634
+ ### Your account's process is binding, and it is already in your brief
635
+
636
+ An account declares how its work is done as Prompts with purpose `OPERATIONS`, selected per the ticket's
637
+ `workstream` (`support`, `sdlc`, `incident`, `release`, or whatever that organization calls its
638
+ processes) via `Prompt.category`, with an uncategorised one as the catch-all. The matching text is
639
+ **inlined verbatim into the brief** of every agent that works one of that account's tickets and is
640
+ binding on it — follow it even where it differs from how you would normally proceed, and if it conflicts
641
+ with the brief, follow the process and say so on the ticket.
642
+
643
+ You do not fetch it: if the brief has a "The process that governs this ticket" section, that is it. If
644
+ it says the account documents no process, the likely cause is that the prompt is **staged but not
645
+ committed** — an autonomous worker resolves the committed one, so it is never bound by a procedure
646
+ nobody has reviewed. The repo root also carries a generated `OPERATIONS.md`; that file is generated
647
+ from the prompt, so **edit the prompt, not the file**.
648
+
649
+ `workstream` is not `type`: a type classifies the request, a workstream names the procedure, and they
650
+ cross — a `defect` handled by incident response out of hours goes through the SDLC in the morning.
651
+
590
652
  ### Seeing the queue as a human does
591
653
 
592
654
  `remits-cli start` opens a browser control center showing the same facts you are acting on: which
@@ -632,10 +694,76 @@ remits-cli ticket progress --ticket 22454 --summary "Traced it to the posting Ac
632
694
  remits-cli ticket status --ticket 22454 --status in_progress
633
695
  remits-cli ticket complete --ticket 22454 --resolution "What you found and did"
634
696
  remits-cli ticket ask --ticket 22454 --question "Re-issue or skip?" --context "412 affected"
635
- remits-cli ticket reply --ticket 22454 --message "Skip them."
697
+ remits-cli ticket answer --ticket 22454 --message "Skip them." # alias: reply
698
+ remits-cli ticket message --ticket 22454 --message "We reproduced it; a fix is staged."
699
+ remits-cli ticket planning --ticket 22454 --board-stage ready_for_qa --blocked-by CAB-112
636
700
  remits-cli ticket release --ticket 22454 # hand it back to the queue
637
701
  ```
638
702
 
703
+ **See the whole queue before deciding your ticket is unique.** Alert-raised tickets arrive in
704
+ clusters, and the same root cause routinely appears under several different account names — so the
705
+ first useful question is usually "how many of these are one fix?", and you cannot ask it from a single
706
+ ticket.
707
+
708
+ ```bash
709
+ remits-cli ticket queue --account-id 49 # triage order, most urgent first
710
+ remits-cli ticket queue --account-id 49 --unrouted --status open
711
+ remits-cli ticket queue --account-id 49 --search "firestore index"
712
+ ```
713
+
714
+ The `repo` column is the account the **edit lease** excludes on, so it also tells you which of these
715
+ could be worked at the same time and which will serialize behind one another.
716
+
717
+ Found a second, unrelated problem while working yours? **File it rather than widening the ticket you
718
+ were given** — a ticket that describes two things cannot be closed by either fix.
719
+
720
+ ```bash
721
+ remits-cli ticket create --account-id 49 --subject "Vendor name lost on re-normalization" \
722
+ --type defect --priority medium --reference-id vendor-name-lost
723
+ ```
724
+
725
+ `--reference-id` makes it get-or-create, so a re-run — or another worker reaching the same conclusion
726
+ — reconciles onto the same ticket instead of filing a duplicate.
727
+
728
+ Marking and evidence, so the next reader does not repeat your work:
729
+
730
+ ```bash
731
+ remits-cli ticket tag --ticket 22454 --tags firestore-index,cluster-aug
732
+ remits-cli ticket artifact --ticket 22454 --type log --label "Failing query" --content "..."
733
+ remits-cli ticket note --ticket 22454 --message "Internal: same cause as 23465"
734
+ remits-cli ticket field --ticket 22454 --key customerReference --value CR-9182
735
+ ```
736
+
737
+ `note` is internal — the next worker and a reviewer see it, the requester does not. Use `message` for
738
+ anything the requester should read. `field` writes your **organization's own** field, outside the
739
+ platform's bounded planning slots.
740
+
741
+ **`message`, `answer` and `note` are separated by who reads the result, not by tone.** They are easy to
742
+ confuse and they do different things to the ticket:
743
+
744
+ | Command | Who reads it | What it does to the ticket |
745
+ |---|---|---|
746
+ | `ticket message --message "..."` | the requester | appends a public entry. **Does not send anything** |
747
+ | `ticket note --message "..."` | the next worker, a reviewer | appends an internal entry |
748
+ | `ticket answer --message "..."` | whoever asked | answers the open question and **re-routes**, so a fresh worker resumes |
749
+
750
+ `ticket reply` is an **alias of `answer`** — kept because every existing brief says it. Do not read it as
751
+ "reply to the customer"; that is `message`. (Some account tools spell the conversational verb `reply`,
752
+ which is exactly why this surface teaches `answer`.)
753
+
754
+ **Appending a message is not emailing anyone.** The record is deliberately vendor-agnostic; the account
755
+ owns the transport. To ask for something to actually go out:
756
+
757
+ ```bash
758
+ remits-cli ticket deliver --ticket 22454 --channel email --to ops@acme.test \
759
+ --subject "Waiting on you" --idempotency-key waiting-22454
760
+ remits-cli ticket deliveries --ticket 22454 # what is queued, and what already went
761
+ ```
762
+
763
+ That records an outbox request. An account-owned Rule or scheduled Action sends it and marks the result
764
+ (`ticket delivered --delivery-id ...` / `ticket delivery-failed --delivery-id ... --reason "..."`). Use
765
+ this when a ticket is waiting on somebody who is not watching the queue.
766
+
639
767
  ### When a worker needs a decision from a human
640
768
 
641
769
  There are **three** ways a run can end, not two, and the third is what stops an agent guessing:
@@ -644,12 +772,14 @@ There are **three** ways a run can end, not two, and the third is what stops an
644
772
  |---|---|---|
645
773
  | Done | `ticket complete --resolution` | finished, here is what I did |
646
774
  | **Blocked on a choice** | `ticket ask --question` | I understand the work; the DECISION is not mine |
775
+
647
776
  | Could not finish | `ticket status --status pending_review` + `ticket progress` | stuck, here is the hand-off |
648
777
 
649
778
  `ask` puts the question on the ticket as an outbound message and parks it. The queue then shows it as
650
779
  **awaiting a response** — distinct from "done, review me", which `pending_review` alone cannot express.
651
780
 
652
- Someone answers with `remits-cli ticket reply --message "..."` (or through an operator Embeddable).
781
+ Someone answers with `remits-cli ticket answer --message "..."` (`reply` is an alias), or through an
782
+ operator Embeddable.
653
783
  That **re-routes the ticket by default**, so the supervisor's next poll starts a **fresh worker whose
654
784
  brief already contains the exchange**. Resumption costs nothing because a worker is one process per
655
785
  ticket — there is no session to restore.
@@ -684,8 +814,9 @@ Ticket-routing context:
684
814
  - For defect investigations, start from the owning ticket account, then move to the platform/product context if the root cause is in shared components.
685
815
 
686
816
  Sandbox note:
687
- - `remits-cli` commands that call the Remits service (`auth`, `tools`, `tool`, `components`, `test`, `token`) require outbound network access.
688
- - In Codex or similar sandboxed agent environments, `ENOTFOUND`, `EAI_AGAIN`, `ECONNREFUSED`, `EPERM`, or similar network errors usually mean the command must be retried with escalated permissions or outside the sandbox.
817
+ - Every `remits-cli` command that reaches the Remits service (`auth`, `tools`, `tool`, `components`, `test`, `token`, and all of `ticket` / `agent`) needs outbound network access.
818
+ - **Inside an autonomous ticket worker this is already arranged** — the supervisor launches the worker with network enabled, in both the editing and the investigating phase. An investigating worker is restricted from *writing the repository*, never from *talking to the platform*: reading its ticket, running a diagnostic and recording a finding are the whole of what investigating means.
819
+ - Elsewhere, `ENOTFOUND`, `EAI_AGAIN`, `ECONNREFUSED`, `EPERM` or a bare exit 6 / `HTTP:000` from a sandboxed session means the command needs escalated permissions or must run outside the sandbox. Do not read it as "the platform is down" — check a second command before concluding anything about the service.
689
820
 
690
821
  Use the same repo/context rules as other tools:
691
822
  - If the ticket targets a `CLIENT` account, do not assume that client's repo is the implementation repo. Confirm the parent `PLATFORM` / `PRODUCT` relationship first.
@@ -975,6 +1106,43 @@ remits-cli tool --name "mcp_run_action" --input '{"accountId":49,"actionId":200,
975
1106
 
976
1107
  Every tool response is saved to `./.remits-cli/tool-responses/<callId>.json`.
977
1108
 
1109
+ ### Hierarchy-scoped tool reads
1110
+
1111
+ For read/discovery tools, the account resolved from the checkout/session is the **scope root**, not proof
1112
+ that the business record is owned by that account. This closes the common support loop where you know a
1113
+ precise document, object, event, or indexed source id but do not yet know which child account owns it.
1114
+
1115
+ Use the tool flags rather than editing JSON by hand:
1116
+
1117
+ ```bash
1118
+ remits-cli tool --name mcp_firestore_search \
1119
+ --input '{"collection":"statements","documentId":"1234"}' \
1120
+ --scope children --data-mode prod
1121
+ ```
1122
+
1123
+ Available flags:
1124
+
1125
+ | Flag | Meaning |
1126
+ |---|---|
1127
+ | `--scope self|children|hierarchy` | Expand from the repo/session account for read/discovery. Exact-id lookups usually default to `children`; broad searches default to `self` unless widened. |
1128
+ | `--target-account-id ID` | Exact owner/execution account when already known. Required by mutating tools. |
1129
+ | `--account-ids 1,2,3` | Explicit bounded owner list. The platform verifies every id against the scope root. |
1130
+ | `--anchor-account-id ID` | Path-disambiguation anchor for multi-parent account relationships. |
1131
+
1132
+ The CLI merges these into `--input`; a value already present in `--input` wins. Tool responses echo
1133
+ `scopeRootAccountId`, `scope`, `accountIds`, and, when an exact owner is discovered,
1134
+ `resolvedTargetAccountId`. Feed that returned owner to `mcp_firestore_patch`, action/test runs, and browser
1135
+ tokens. Mutating tools do not infer or fan out writes.
1136
+
1137
+ The implicit account ceiling is intentionally different by shape: broad searches stay capped at 100 accounts
1138
+ unless the tool says otherwise, while exact-id discovery may span up to 1000 accounts by default. If a broad
1139
+ tool returns `scopeTooBroad`, narrow with `--target-account-id`, `--account-ids`, or a smaller `--scope`.
1140
+
1141
+ `mcp_firestore_search` handles exact Firestore document ids. `mcp_index_search` handles fuzzy/semantic
1142
+ lookup through Vertex. `mcp_bigquery_query` handles warehouse lookup/query with server-resolved
1143
+ `{table_current}`/`{table}` placeholders and the scoped `{account_filter}` predicate. BigQuery is a prod
1144
+ analytics surface, so use `--data-mode prod` when you intend to query it.
1145
+
978
1146
  Poll a CLI-transport async call (mechanism 2) by call id:
979
1147
 
980
1148
  ```bash
@@ -3181,6 +3349,20 @@ remits-cli agent release
3181
3349
  remits-cli ticket read|accept|status|progress|complete|release|reopen|assign|planning --ticket ID [--status S] [--resolution "..."] [--summary "..."] [--category C] [--assignee EMAIL] [--workstream VALUE] [--planned-in VALUE] [--board-stage VALUE] [--rank N] [--size VALUE] [--blocked-by VALUE] [--notes "..."]
3182
3350
  remits-cli ticket lease|unlease|force-unlease --ticket ID [--reason "..."] # force-unlease breaks SOMEBODY ELSE'S lease; operator only
3183
3351
  remits-cli ticket where --ticket ID # WHERE this work is: repo account, checkout, branch, staging lane, live lease + holder's location, claim, your phase
3352
+ remits-cli ticket ask --ticket ID --question "..." [--context "..."] [--to WHO] # park on a human decision
3353
+ remits-cli ticket answer --ticket ID --message "..." [--route false] # answer it (alias: reply); re-routes by default
3354
+ remits-cli ticket message --ticket ID --message "..." [--to a@x,b@y] [--subject "..."] [--channel email] # PUBLIC — the requester reads it
3355
+ remits-cli ticket note --ticket ID --message "..." # INTERNAL — the next worker and a reviewer read it
3356
+ remits-cli ticket deliver --ticket ID --channel email --to a@x [--template T] [--subject "..."] [--idempotency-key K] # ask for it to be SENT
3357
+ remits-cli ticket deliveries --ticket ID [--channel email] # what is queued to go out, and what already went
3358
+ remits-cli ticket delivered --ticket ID --delivery-id ID | ticket delivery-failed --ticket ID --delivery-id ID --reason "..."
3359
+ remits-cli ticket tag --ticket ID --tags a,b # make a cluster of near-identical tickets visible as one
3360
+ remits-cli ticket artifact --ticket ID --type TYPE --label "..." [--url U | --content "..."]
3361
+ remits-cli ticket participant --ticket ID --email E [--role watcher|requester|agent]
3362
+ remits-cli ticket field --ticket ID --key K --value V # the ORGANIZATION's own field, outside the planning slots
3363
+ remits-cli ticket reclaim [--ticket ID | --account-id ID] [--stale-hours 24] [--apply] # OPERATOR: take back an agent's abandoned ticket
3364
+ remits-cli ticket queue --account-id ID [--status ...] [--unrouted] [--unassigned] [--awaiting-response] [--workstream W] [--board-stage S] [--planned-in P] [--search "..."] [--sort-by ...] [--json]
3365
+ remits-cli ticket create --account-id ID --subject "..." --type defect|question|task|incident|enhancement [--priority P] [--description "..."] [--tags a,b] [--workstream W] [--affected-component C] [--implementation-account-id ID] [--reference-id KEY]
3184
3366
  remits-cli start [--foreground true] [--port 8787]
3185
3367
  remits-cli stop
3186
3368
  remits-cli status [--base-url URL] [--account-id ID] [--data-mode test|prod] [--json]