@remits/remits-cli 0.1.167 → 0.1.169

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -4,18 +4,18 @@
4
4
  ## Table of Contents
5
5
 
6
6
  - L22 Runtime Bootstrap And Shared State
7
- - L126 Sessions, Account Resolution, And Production Guards
8
- - L632 Local State, Workspaces, And Verification Evidence
9
- - L2670 Account Repos, Guide Sync, And Platform Repo
10
- - L3569 Component Discovery And HTTP Logging
11
- - L4339 Skill Delivery And TOC Resolution
12
- - L4654 Auth And Component Staging
13
- - L5286 Component Summaries, Status, And Sync Gates
14
- - L7610 Branches, Promotion, Commit, And Test Runs
15
- - L9873 Tokens, Tools, Verification, And Config
16
- - L11390 Service Dashboard And WebSocket Listener
17
- - L13900 Agent And Ticket Workflows
18
- - L18308 Help, Auto Update, And Command Dispatch
7
+ - L128 Sessions, Account Resolution, And Production Guards
8
+ - L634 Local State, Workspaces, And Verification Evidence
9
+ - L2802 Account Repos, Guide Sync, And Platform Repo
10
+ - L3701 Component Discovery And HTTP Logging
11
+ - L4484 Skill Delivery And TOC Resolution
12
+ - L4799 Auth And Component Staging
13
+ - L5435 Component Summaries, Status, And Sync Gates
14
+ - L7759 Branches, Promotion, Commit, And Test Runs
15
+ - L10072 Tokens, Tools, Verification, And Config
16
+ - L11589 Service Dashboard And WebSocket Listener
17
+ - L14099 Agent And Ticket Workflows
18
+ - L18869 Help, Auto Update, And Command Dispatch
19
19
  */
20
20
 
21
21
  /*
@@ -34,6 +34,8 @@ const { Client } = require('@stomp/stompjs');
34
34
  const WebSocket = require('ws');
35
35
  const { installWebsocketRetry } = require('./lib/websocket-retry');
36
36
  const YAML = require('yaml');
37
+ // Invocation identity is separate from the local log-folder actor and staging workspace.
38
+ let requestIdentityFlags = {};
37
39
 
38
40
  Object.assign(global, { WebSocket });
39
41
  const openBrowser = (openModule && typeof openModule === 'function')
@@ -2095,6 +2097,7 @@ function recordEvidenceEntry(cwd, packet, context = {}) {
2095
2097
  };
2096
2098
  fs.appendFileSync(paths.evidenceFile, JSON.stringify(entry) + '\n');
2097
2099
  trimEvidenceTrail(paths.evidenceFile);
2100
+ queueEvidenceReceipt(cwd, entry, context);
2098
2101
  return entry;
2099
2102
  } catch (_) {
2100
2103
  // A trail write must never fail the command it is describing.
@@ -2102,6 +2105,135 @@ function recordEvidenceEntry(cwd, packet, context = {}) {
2102
2105
  }
2103
2106
  }
2104
2107
 
2108
+ /**
2109
+ * Shared passive evidence: inside a work attempt (a supervised worker, or a command given a ticket), each
2110
+ * trail entry is ALSO written to a durable local outbox and published to the work item as a client-reported
2111
+ * receipt, so a successor on another machine can retrieve it. Write before upload; a stable receiptId makes
2112
+ * a replay after a lost response record nothing twice. Never a verdict and never something to satisfy.
2113
+ */
2114
+ const EVIDENCE_OUTBOX_MAX_LINES = 1000;
2115
+
2116
+ function evidenceOutboxFile(cwd, actorId) {
2117
+ // A supervisor drains its CHILD's outbox: the child's state lives under its own actor (REMITS_AGENT_ID),
2118
+ // not under the supervisor's, so the actor can be named explicitly.
2119
+ if (actorId) {
2120
+ return path.join(cwd, '.remits-cli', 'actors', normalizeLocalActor(actorId) || String(actorId), 'evidence-outbox.jsonl');
2121
+ }
2122
+ return path.join(path.dirname(localStatePaths(cwd).evidenceFile), 'evidence-outbox.jsonl');
2123
+ }
2124
+
2125
+ function evidenceReceiptId(entry, ticketId) {
2126
+ const crypto = require('crypto');
2127
+ return 'r-' + crypto.createHash('sha256').update(String(ticketId) + '|' + JSON.stringify(entry)).digest('hex').slice(0, 20);
2128
+ }
2129
+
2130
+ function queueEvidenceReceipt(cwd, entry, context = {}) {
2131
+ try {
2132
+ const ticketId = String((context && context.ticketId) || process.env.REMITS_SUPPORT_TICKET_ID || '').trim();
2133
+ if (!ticketId || !entry) return null;
2134
+ const receipt = {
2135
+ receiptId: evidenceReceiptId(entry, ticketId),
2136
+ ticketId,
2137
+ // Partitioned by platform: a receipt queued against one host is never uploaded to another.
2138
+ host: entry.world && entry.world.host || (context && context.baseUrl) || null,
2139
+ attemptId: process.env.REMITS_WORKER_RUN_ID || null,
2140
+ type: entry.type,
2141
+ claim: entry.claim,
2142
+ outcome: evidenceOutcome(entry),
2143
+ observedAt: entry.ts,
2144
+ world: entry.world,
2145
+ ref: { packetId: entry.ref && entry.ref.packetId, taskId: entry.ref && entry.ref.taskId, callId: entry.ref && entry.ref.callId },
2146
+ queuedAt: new Date().toISOString()
2147
+ };
2148
+ fs.appendFileSync(evidenceOutboxFile(cwd), JSON.stringify(receipt) + '\n');
2149
+ return receipt;
2150
+ } catch (_) {
2151
+ return null;
2152
+ }
2153
+ }
2154
+
2155
+ /**
2156
+ * Publish queued receipts. The outbox is RENAMED before it is read, so a concurrent writer appends to a
2157
+ * fresh file and nothing it writes can be lost by this rewrite; whatever is not accepted is appended back.
2158
+ * Returns { sent, duplicate, kept, rejected }.
2159
+ */
2160
+ async function drainEvidenceOutbox(cwd, context, actorId) {
2161
+ const file = evidenceOutboxFile(cwd, actorId);
2162
+ const summary = { sent: 0, duplicate: 0, kept: 0, rejected: 0 };
2163
+ // A drain that crashed mid-flight left its renamed file behind; its receipts go back in the queue.
2164
+ try {
2165
+ const dir = path.dirname(file);
2166
+ for (const name of fs.readdirSync(dir)) {
2167
+ const m = /^evidence-outbox\.jsonl\.(\d+)\.draining$/.exec(name);
2168
+ if (m && Number(m[1]) !== process.pid && !processIsAlive(Number(m[1]))) {
2169
+ const orphan = path.join(dir, name);
2170
+ fs.appendFileSync(file, fs.readFileSync(orphan, 'utf8'));
2171
+ fs.unlinkSync(orphan);
2172
+ }
2173
+ }
2174
+ } catch (_) { /* best effort */ }
2175
+ if (!fs.existsSync(file) || !context || !context.session) return summary;
2176
+ const draining = file + '.' + process.pid + '.draining';
2177
+ try { fs.renameSync(file, draining); } catch (_) { return summary; }
2178
+ let receipts = [];
2179
+ try {
2180
+ receipts = fs.readFileSync(draining, 'utf8').split('\n').filter(Boolean)
2181
+ .map((line) => { try { return JSON.parse(line); } catch (_) { return null; } }).filter(Boolean);
2182
+ } catch (_) { /* unreadable: keep nothing */ }
2183
+ const keep = [];
2184
+ const host = String(context.baseUrl || '').replace(/\/$/, '');
2185
+ // Grouped by ticket AND the data lane the receipt was observed in: a ticket lives in one lane, and
2186
+ // addressing it from another (e.g. the draining supervisor's session lane) finds no document there.
2187
+ const byTicket = new Map();
2188
+ for (const r of receipts) {
2189
+ // Another platform's receipts stay queued for that platform.
2190
+ if (r.host && host && String(r.host).replace(/\/$/, '') !== host) { keep.push(r); continue; }
2191
+ const lane = (r.world && r.world.dataMode) || context.dataMode || null;
2192
+ const groupKey = r.ticketId + '|' + (lane || '');
2193
+ if (!byTicket.has(groupKey)) byTicket.set(groupKey, { ticketId: r.ticketId, lane, list: [] });
2194
+ byTicket.get(groupKey).list.push(r);
2195
+ }
2196
+ const api = buildAxios(context.baseUrl, context.session.token, 20000);
2197
+ for (const { ticketId, lane, list } of byTicket.values()) {
2198
+ for (let i = 0; i < list.length; i += 50) {
2199
+ const batch = list.slice(i, i + 50);
2200
+ try {
2201
+ const response = await api.post('/cli/ticket', { action: 'receipt', ticketId, dataMode: lane || undefined, receipts: batch })
2202
+ .then((res) => res.data);
2203
+ if (response && response.success) {
2204
+ summary.sent += response.accepted || 0;
2205
+ summary.duplicate += response.duplicate || 0;
2206
+ summary.rejected += (response.rejected || []).length;
2207
+ } else {
2208
+ keep.push(...batch);
2209
+ }
2210
+ } catch (err) {
2211
+ // Only a request the platform says is malformed or not permitted is dropped (loudly). Everything else —
2212
+ // unreachable, busy (409 also covers a ticket mid-write or a stale revision), throttled, not found yet —
2213
+ // stays queued for the next drain: losing evidence is worse than retrying it.
2214
+ const status = err && err.response && err.response.status;
2215
+ const message = err && err.response && err.response.data && err.response.data.message;
2216
+ if (status === 400 || status === 401 || status === 403 || status === 413 || status === 422) {
2217
+ summary.rejected += batch.length;
2218
+ appendGlobalActivityLog('evidence', 'outbox.rejected', { ticketId, count: batch.length, status, message }, 'error');
2219
+ } else {
2220
+ keep.push(...batch);
2221
+ appendGlobalActivityLog('evidence', 'outbox.kept', { ticketId, count: batch.length, status: status || null,
2222
+ message: message || describeError(err) }, 'warn');
2223
+ }
2224
+ }
2225
+ }
2226
+ }
2227
+ try {
2228
+ if (keep.length) {
2229
+ fs.appendFileSync(file, keep.slice(-EVIDENCE_OUTBOX_MAX_LINES).map((r) => JSON.stringify(r)).join('\n') + '\n');
2230
+ }
2231
+ fs.unlinkSync(draining);
2232
+ } catch (_) { /* the draining file remains and is retried by name below */ }
2233
+ summary.kept = keep.length;
2234
+ return summary;
2235
+ }
2236
+
2105
2237
  function evidenceOutcome(row) {
2106
2238
  if (row && (row.pending === true || row.success === null)) return 'pending';
2107
2239
  return row && row.success === false ? 'failed' : 'ok';
@@ -4044,8 +4176,12 @@ function stageTimeoutMs(components, flags = {}) {
4044
4176
  return Math.min(600000, Math.max(60000, Math.round(60000 + (megabytes * 60000))));
4045
4177
  }
4046
4178
 
4047
- function buildAxios(baseUrl, token, timeoutMs = 60000) {
4048
- const headers = token ? { Authorization: 'Bearer ' + token } : {};
4179
+ function cliRequestHeaders(token) {
4180
+ // A supervised worker authenticates with the credential its supervisor minted for THIS attempt
4181
+ // (/cli/agentCredential). The platform then knows which participant is calling from the credential itself,
4182
+ // so omitting or mistyping an identity header cannot turn the worker into a human or another agent.
4183
+ const effectiveToken = process.env.REMITS_AGENT_TOKEN || token;
4184
+ const headers = effectiveToken ? { Authorization: 'Bearer ' + effectiveToken } : {};
4049
4185
  // The CLI version on EVERY request, from the one place every request is built. It used to travel only on
4050
4186
  // `agent register`, so the platform knew the version of the sessions that registered - and the agents that
4051
4187
  // loop hardest are exactly the ones that never do. Without it the platform cannot tell an agent that the
@@ -4055,10 +4191,19 @@ function buildAxios(baseUrl, token, timeoutMs = 60000) {
4055
4191
  // A worker's run identity rides on every call, so a verb whose payload does not repeat it (stage, commit,
4056
4192
  // a tool) is still attributed to the run. The ROLE is deliberately not sent: the platform reads it from
4057
4193
  // the session's registration, where the user's permission to declare it was checked.
4058
- if (process.env.REMITS_AGENT_ID) headers['X-Remits-Agent-Id'] = process.env.REMITS_AGENT_ID;
4194
+ // ticket route historically accepts --agent-id as the TARGET; it is not the caller.
4195
+ const targetAgent = requestIdentityFlags._?.[0] === 'ticket' && ['route', 'unroute'].includes(requestIdentityFlags._?.[1]);
4196
+ const rawAgentFlag = requestIdentityFlags['agent-id'] || requestIdentityFlags.agentId;
4197
+ const flagAgent = typeof rawAgentFlag === 'string' ? rawAgentFlag.trim() : '';
4198
+ const agentId = (!targetAgent && flagAgent) || process.env.REMITS_AGENT_ID;
4199
+ if (typeof agentId === 'string' && agentId.trim()) headers['X-Remits-Agent-Id'] = agentId.trim();
4059
4200
  if (process.env.REMITS_WORKER_RUN_ID) headers['X-Remits-Run-Id'] = process.env.REMITS_WORKER_RUN_ID;
4060
4201
  if (process.env.REMITS_SUPPORT_TICKET_ID) headers['X-Remits-Ticket-Id'] = process.env.REMITS_SUPPORT_TICKET_ID;
4061
- return axios.create({ baseURL: baseUrl, timeout: parsePositiveInt(timeoutMs, 60000), headers });
4202
+ return headers;
4203
+ }
4204
+
4205
+ function buildAxios(baseUrl, token, timeoutMs = 60000) {
4206
+ return axios.create({ baseURL: baseUrl, timeout: parsePositiveInt(timeoutMs, 60000), headers: cliRequestHeaders(token) });
4062
4207
  }
4063
4208
 
4064
4209
  function describeError(err) {
@@ -4988,6 +5133,10 @@ async function pushComponentsCommand(flags) {
4988
5133
  ...runContextPayload(flags),
4989
5134
  agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined,
4990
5135
  acknowledge: flags.acknowledge || undefined,
5136
+ // `commit` when this stage is the pre-push check of `components commit`: the platform then judges it as a
5137
+ // LANDING (the account's committed `commit`-point rules, component rules included), so a refusal
5138
+ // arrives before anything is pushed instead of after.
5139
+ intent: flags.intent || undefined,
4991
5140
  // What this stage IS, stated rather than implied. The server derives the lane behavior from the mode
4992
5141
  // (CliComponentStaging.stageRequest) instead of from `replace`, which was overloaded: a partial
4993
5142
  // manifest with `replace: true` would make the reconcile pass delete nearly the whole lane, so
@@ -8073,7 +8222,7 @@ async function commitComponentsCommand(flags) {
8073
8222
  };
8074
8223
  try {
8075
8224
  if (!skipGit) {
8076
- emit('Phase 1/4: staged compile validation');
8225
+ emit('Phase 1/4: staged compile validation and landing policy check');
8077
8226
  // MERGE semantics on purpose (`--changed-only`, never `--workset`). A workset stage RECONCILES the
8078
8227
  // lane — it deletes every entry outside the git changed set — and the lane is frequently shared. A
8079
8228
  // validation pass must not be able to delete another agent's staged work as a side effect of somebody
@@ -8085,7 +8234,8 @@ async function commitComponentsCommand(flags) {
8085
8234
  workset: false,
8086
8235
  'replace-lane': false,
8087
8236
  replaceLane: false,
8088
- 'changed-only': true
8237
+ 'changed-only': true,
8238
+ intent: 'commit'
8089
8239
  })));
8090
8240
 
8091
8241
  outcome.phase = 'git';
@@ -9115,6 +9265,27 @@ function testRunSummaryLines(status = {}) {
9115
9265
  return lines;
9116
9266
  }
9117
9267
 
9268
+ // A full-suite run that took minutes is the most expensive thing an agent's build loop does, and the run itself
9269
+ // is the one place to say so. Production 2026-11: agents re-ran whole multi-minute suites after every edit,
9270
+ // every case passing. Printed only for an unselected run long enough (90 s of case time) to matter.
9271
+ function testRunPacingLines(status = {}, options = {}) {
9272
+ const minMs = 90 * 1000;
9273
+ const result = status.result || {};
9274
+ const tests = Array.isArray(result.tests) ? result.tests.filter((t) => t && t.name) : [];
9275
+ if (options.selected || tests.length < 4) return [];
9276
+ const durationOf = (t) => Number(t.duration) || 0;
9277
+ const totalMs = tests.reduce((sum, t) => sum + durationOf(t), 0);
9278
+ if (totalMs < minMs) return [];
9279
+ const slowest = tests.slice().sort((a, b) => durationOf(b) - durationOf(a)).slice(0, 3)
9280
+ .filter((t) => durationOf(t) > 0);
9281
+ const lines = ['Pacing: ' + tests.length + ' cases, ' + formatDurationMs(totalMs) + ' of case time' +
9282
+ (slowest.length ? '; slowest ' + slowest.map((t) => JSON.stringify(String(t.name)) + ' ' + formatDurationMs(durationOf(t))).join(', ') : '') + '.'];
9283
+ const ref = options.testRef !== undefined ? JSON.stringify(String(options.testRef)) : '<suite>';
9284
+ lines.push(' While iterating, re-run only what you are fixing: remits-cli test run --test ' + ref + ' --failed (or --names "a|b").');
9285
+ lines.push(' Run the whole suite once before landing.');
9286
+ return lines;
9287
+ }
9288
+
9118
9289
  // Per-case comparison of two runs of the same suite: which cases changed outcome, and how cost and duration
9119
9290
  // moved. The answer to "did this revision do better than the last one" without reconstructing either run.
9120
9291
  function compareTestRunResults(baseResult = {}, headResult = {}) {
@@ -9333,6 +9504,13 @@ function testRunLaunchLines(start = {}, runOptions = {}) {
9333
9504
  if (runOptions.label) lines.push('Label: ' + runOptions.label);
9334
9505
  if (runOptions.params) lines.push('Params: ' + Object.keys(runOptions.params).map((k) => k + '=' + JSON.stringify(runOptions.params[k])).join(' '));
9335
9506
  if (runOptions.repeat) lines.push('Repeat: every selected case runs ' + runOptions.repeat + ' times');
9507
+ if (start.rerunOf && Array.isArray(start.rerunOf.failedCases)) {
9508
+ const r = start.rerunOf;
9509
+ lines.push('Re-running ' + r.failedCases.length + ' case(s) that did not pass in run ' + r.taskId +
9510
+ (r.dataMode ? ' (' + r.dataMode + ' lane)' : '') + ':');
9511
+ r.failedCases.slice(0, 20).forEach((name) => lines.push(' - ' + name));
9512
+ if (r.failedCases.length > 20) lines.push(' ... and ' + (r.failedCases.length - 20) + ' more');
9513
+ }
9336
9514
  if (start.runGroupId) {
9337
9515
  lines.push('Parallel: ' + (start.shardTaskIds || []).length + ' shards (' + (start.shardTaskIds || []).join(', ') + '), read as ONE run by ' + start.runGroupId);
9338
9516
  }
@@ -9367,6 +9545,10 @@ async function testCommand(flags) {
9367
9545
  ? []
9368
9546
  : (Array.isArray(flags.names) ? flags.names : [flags.names]);
9369
9547
  const names = parseTestCaseSelectors(rawNames);
9548
+ const rerunFailed = flagEnabled(flags.failed);
9549
+ if (rerunFailed && names.length) {
9550
+ throw new Error('--failed picks the cases itself; drop --names (or drop --failed).');
9551
+ }
9370
9552
  // --label / --param / --repeat / --parallel: what this run varies, and how it is executed.
9371
9553
  const runOptions = testRunOptionsFromFlags(flags);
9372
9554
  const runGroupId = runOptions.parallel > 1 ? 'grp-' + crypto.randomUUID() : undefined;
@@ -9416,6 +9598,11 @@ async function testCommand(flags) {
9416
9598
  dataMode,
9417
9599
  dataModeSource,
9418
9600
  caseConcurrency: flags.parallel !== undefined && runOptions.parallel === 1 ? 1 : undefined,
9601
+ // --candidate <laneContentHash>: refuse (stale_revision) if the lane no longer holds that staged content.
9602
+ expectedLaneContentHash: flags.candidate || undefined,
9603
+ // --failed: the platform re-runs only the cases that did not pass in your last finished run of this suite
9604
+ // on this git branch and staging workspace (and says which run it read).
9605
+ rerunFailed: rerunFailed || undefined,
9419
9606
  testId: /^\d+$/.test(String(testRef)) ? Number(testRef) : undefined,
9420
9607
  testName: /^\d+$/.test(String(testRef)) ? undefined : String(testRef),
9421
9608
  tests: names
@@ -9434,6 +9621,7 @@ async function testCommand(flags) {
9434
9621
  break;
9435
9622
  } catch (err) {
9436
9623
  const data = err && err.response && err.response.data;
9624
+ if (data && data.code === 'no_recorded_run') throw new Error(data.message);
9437
9625
  if (shardStarts.length && shardStartBusy(err) && Date.now() < shardWaitDeadline) {
9438
9626
  console.error('Shard ' + (shardStarts.length + 1) + ' of ' + runOptions.parallel + ' is waiting for a free suite slot ' +
9439
9627
  '(the platform runs a limited number of suites at once; the shards already running free them).');
@@ -9446,8 +9634,18 @@ async function testCommand(flags) {
9446
9634
  throw err;
9447
9635
  }
9448
9636
  }
9637
+ if (!shardStart.success && shardStart.code === 'nothing_failed' && !shardStarts.length) {
9638
+ // Not an error: the thing --failed exists for is already true. Exit 0 and say what to run next.
9639
+ if (jsonOutput) {
9640
+ console.log(JSON.stringify(shardStart, null, 2));
9641
+ } else {
9642
+ console.log(shardStart.message);
9643
+ console.log('Before landing, run the whole suite once: remits-cli test run --test ' + JSON.stringify(String(testRef)));
9644
+ }
9645
+ return;
9646
+ }
9449
9647
  if (!shardStart.success) {
9450
- throw new Error('Failed to start test run' + (shardStarts.length ? ' (shard ' + (shardStarts.length + 1) + ' of ' + runOptions.parallel +
9648
+ throw new Error('Failed to start test run' + (shardStart.message ? ': ' + shardStart.message : '') + (shardStarts.length ? ' (shard ' + (shardStarts.length + 1) + ' of ' + runOptions.parallel +
9451
9649
  '; the shards already started keep running: ' + shardStarts.map((st) => st.taskId).join(', ') + ')' : ''));
9452
9650
  }
9453
9651
  shardStarts.push(shardStart);
@@ -9548,6 +9746,7 @@ async function testCommand(flags) {
9548
9746
  .forEach((line) => console.log(line));
9549
9747
  testRunSummaryLines(status).forEach((line) => console.log(line));
9550
9748
  testFailureGroupLines(status, { testRef }).forEach((line) => console.log(line));
9749
+ testRunPacingLines(status, { testRef, selected: names.length > 0 || rerunFailed }).forEach((line) => console.log(line));
9551
9750
  printTestRunPivots(status);
9552
9751
  // The whole run object used to be printed here as pretty JSON. One 92-case suite measured 220 KB,
9553
9752
  // most of it identifiers repeated once per case, spent on the turn with the least room left. It is
@@ -13959,7 +14158,7 @@ const AGENT_WORKER_MAX_LOG_BYTES = 8 * 1024 * 1024;
13959
14158
  *
13960
14159
  * `mode` is the autonomy bound, not a preference. `investigate` maps to each CLI's genuine
13961
14160
  * read-only mode so an operator can put a real boundary around a production support agent; `edit`
13962
- * lets it change files but never commits (the brief says so, and nothing here runs git).
14161
+ * lets it change files; whether it may land is the account's landing profile, stated in the brief (nothing here runs git).
13963
14162
  */
13964
14163
  const AGENT_LAUNCHERS = {
13965
14164
  codex: {
@@ -15234,6 +15433,84 @@ async function postWorkerSummary(context, agentId, ticket, summaryFile, options
15234
15433
  }
15235
15434
  }
15236
15435
 
15436
+ /**
15437
+ * What a worker run cost, read from its own JSONL transcript — the only place a local model's spend is
15438
+ * visible. A Claude run's `result` event carries `total_cost_usd` and token usage; a Codex run's
15439
+ * `turn.completed` events carry token usage and no price. Returns null when the transcript says nothing,
15440
+ * so an unknown cost is reported as unknown rather than as zero.
15441
+ */
15442
+ function workerRunUsage(text) {
15443
+ let usd = null;
15444
+ let inputTokens = 0;
15445
+ let outputTokens = 0;
15446
+ let sawTokens = false;
15447
+ String(text || '').split('\n').forEach((line) => {
15448
+ let event;
15449
+ try { event = JSON.parse(line.trim()); } catch (_) { return; }
15450
+ if (!event || typeof event !== 'object') return;
15451
+ if (event.type === 'result') {
15452
+ if (typeof event.total_cost_usd === 'number') usd = event.total_cost_usd;
15453
+ if (event.usage) {
15454
+ inputTokens = Number(event.usage.input_tokens || 0) + Number(event.usage.cache_read_input_tokens || 0) +
15455
+ Number(event.usage.cache_creation_input_tokens || 0);
15456
+ outputTokens = Number(event.usage.output_tokens || 0);
15457
+ sawTokens = true;
15458
+ }
15459
+ } else if (event.type === 'turn.completed' && event.usage) {
15460
+ inputTokens += Number(event.usage.input_tokens || 0);
15461
+ outputTokens += Number(event.usage.output_tokens || 0);
15462
+ sawTokens = true;
15463
+ }
15464
+ });
15465
+ if (usd === null && !sawTokens) return null;
15466
+ return { usd, inputTokens: sawTokens ? inputTokens : null, outputTokens: sawTokens ? outputTokens : null };
15467
+ }
15468
+
15469
+ /**
15470
+ * The identity a worker attempt's cost is reported under. The platform keeps ONE report per identity (a
15471
+ * re-sent report replaces itself), so it must be unique per ATTEMPT, not per supervisor: the supervisor's
15472
+ * run id is constant for its whole life, and reusing it made each retry overwrite the previous attempt's
15473
+ * spend. The attempt's transcript file is unique per spawn and stable for that attempt, which is exactly
15474
+ * the identity a retried report must keep.
15475
+ */
15476
+ function workerCostReportId(runId, logFile) {
15477
+ return String(runId || 'run') + ':' + path.basename(String(logFile || 'attempt'), '.log');
15478
+ }
15479
+
15480
+ /** Report a finished run's cost onto its ticket. Best-effort: a lost report under-counts, never fails a run. */
15481
+ async function reportWorkerCost(context, agentId, ticket, logFile, runId, agentType) {
15482
+ let usage = null;
15483
+ try {
15484
+ usage = workerRunUsage(fs.readFileSync(logFile, 'utf8'));
15485
+ } catch (_) {
15486
+ return false;
15487
+ }
15488
+ if (!usage || !runId) return false;
15489
+ try {
15490
+ await buildAxios(context.baseUrl, context.session.token, 20000)
15491
+ .post('/cli/ticket', {
15492
+ action: 'cost',
15493
+ ticketId: ticket.ticketId || ticket.id,
15494
+ accountId: Number(ticket.accountId) || undefined,
15495
+ dataMode: context.dataMode,
15496
+ agentId,
15497
+ runId,
15498
+ costRunId: workerCostReportId(runId, logFile),
15499
+ usd: usage.usd,
15500
+ inputTokens: usage.inputTokens,
15501
+ outputTokens: usage.outputTokens,
15502
+ provider: agentType || undefined,
15503
+ costSource: 'remits-cli-worker'
15504
+ });
15505
+ return true;
15506
+ } catch (err) {
15507
+ appendGlobalActivityLog('agent', 'worker.cost_post_failed', {
15508
+ agentId, ticketId: ticket.ticketId || ticket.id, error: describeError(err)
15509
+ }, 'error');
15510
+ return false;
15511
+ }
15512
+ }
15513
+
15237
15514
  /** Drop a claim on the platform. Best-effort: the claim expires on its own if this never lands. */
15238
15515
  async function releaseTicketClaim(context, agentId, ticket, options = {}) {
15239
15516
  try {
@@ -15260,6 +15537,22 @@ async function releaseTicketClaim(context, agentId, ticket, options = {}) {
15260
15537
  }
15261
15538
  }
15262
15539
 
15540
+ /**
15541
+ * Per-attempt liveness, in memory in the supervisor: when each child last wrote output and its last
15542
+ * summarized progress line. Reported on the supervisor's poll as `attempts`, so the platform can tell a
15543
+ * connected supervisor from a child that is actually producing work. Never persisted; never a heuristic.
15544
+ */
15545
+ const WORKER_LIVENESS = new Map();
15546
+
15547
+ function noteWorkerLiveness(ticketId, patch) {
15548
+ const key = String(ticketId);
15549
+ WORKER_LIVENESS.set(key, { ...(WORKER_LIVENESS.get(key) || { ticketId: key }), ...patch });
15550
+ }
15551
+
15552
+ function workerLivenessFor(ticketIds) {
15553
+ return (ticketIds || []).map((id) => WORKER_LIVENESS.get(String(id))).filter(Boolean);
15554
+ }
15555
+
15263
15556
  async function postAgentStatus(context, agentId, update) {
15264
15557
  try {
15265
15558
  await buildAxios(context.baseUrl, context.session.token, 10000)
@@ -15276,6 +15569,39 @@ async function postAgentStatus(context, agentId, update) {
15276
15569
  * the same thing. Only the local supervisor can add machine-local repository hints; the worker owns the
15277
15570
  * final grounding step, including creating or choosing a git worktree before it edits.
15278
15571
  */
15572
+ /** Mint a credential bound to one worker attempt. Null (legacy identity) when the server cannot. */
15573
+ async function mintWorkerCredential(context, binding) {
15574
+ try {
15575
+ const response = await buildAxios(context.baseUrl, context.session.token, 15000)
15576
+ .post('/cli/agentCredential', {
15577
+ mode: 'mint', agentId: binding.agentId, ticketId: binding.ticketId, runId: binding.runId,
15578
+ capabilities: binding.capabilities
15579
+ })
15580
+ .then((r) => r.data);
15581
+ if (response && response.success && response.credential) {
15582
+ appendGlobalActivityLog('agent', 'worker.credential_minted', {
15583
+ agentId: binding.agentId, ticketId: binding.ticketId, expiresAt: response.expiresAt
15584
+ });
15585
+ return { credential: response.credential, expiresAt: response.expiresAt };
15586
+ }
15587
+ } catch (err) {
15588
+ appendGlobalActivityLog('agent', 'worker.credential_unavailable', {
15589
+ agentId: binding.agentId, ticketId: binding.ticketId, error: describeError(err)
15590
+ }, 'warn');
15591
+ }
15592
+ return null;
15593
+ }
15594
+
15595
+ async function revokeWorkerCredential(context, workerCredential) {
15596
+ if (!workerCredential || !workerCredential.credential) return;
15597
+ try {
15598
+ await buildAxios(context.baseUrl, context.session.token, 15000)
15599
+ .post('/cli/agentCredential', { mode: 'revoke', credential: workerCredential.credential });
15600
+ } catch (_) {
15601
+ // It expires on its own; a failed revoke only shortens nothing.
15602
+ }
15603
+ }
15604
+
15279
15605
  async function spawnTicketWorker(context, ctx, ticket) {
15280
15606
  const { spawn } = require('child_process');
15281
15607
  const agentId = ctx.agentId;
@@ -15401,6 +15727,15 @@ async function spawnTicketWorker(context, ctx, ticket) {
15401
15727
  });
15402
15728
 
15403
15729
  const attempt = recordWorkerAttempt(agentId, ticketId);
15730
+ // A credential bound to this attempt (agent + ticket + run). Best effort: an older server without the
15731
+ // endpoint leaves the worker on the legacy header-declared identity, exactly as before.
15732
+ // A session the OPERATOR launched read-only (--mode investigate) gets a credential WITHOUT stage/land, so
15733
+ // the platform refuses source changes from it rather than relying on the brief alone. A worker that is
15734
+ // investigating only because another run holds the lease keeps full reach: it may take the lease later.
15735
+ const workerCredential = await mintWorkerCredential(context, {
15736
+ agentId, ticketId, runId,
15737
+ capabilities: ctx.mode === 'investigate' ? ['read', 'ticket', 'test', 'tool', 'token'] : undefined
15738
+ });
15404
15739
  const child = spawn(launcher.binary, built.args, {
15405
15740
  cwd: workingDirectory || ctx.cwd,
15406
15741
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -15411,6 +15746,7 @@ async function spawnTicketWorker(context, ctx, ticket) {
15411
15746
  REMITS_AGENT_ID: agentId,
15412
15747
  REMITS_SUPPORT_TICKET_ID: ticketId,
15413
15748
  REMITS_WORKER_RUN_ID: runId,
15749
+ ...(workerCredential ? { REMITS_AGENT_TOKEN: workerCredential.credential } : {}),
15414
15750
  // Give the worker its OWN staging lane.
15415
15751
  //
15416
15752
  // The edit LEASE already serializes who may edit a repo, and this does not replace it — a lease
@@ -15472,8 +15808,10 @@ async function spawnTicketWorker(context, ctx, ticket) {
15472
15808
  let lastStatusAtMs = 0;
15473
15809
  let buffered = '';
15474
15810
 
15811
+ noteWorkerLiveness(ticketId, { runId, pid: child.pid, startedAtMs: Date.now() });
15475
15812
  const consume = (chunk) => {
15476
15813
  const text = chunk.toString();
15814
+ noteWorkerLiveness(ticketId, { lastOutputAtMs: Date.now() });
15477
15815
  if (logBytes < AGENT_WORKER_MAX_LOG_BYTES) {
15478
15816
  logBytes += text.length;
15479
15817
  logStream.write(text);
@@ -15489,6 +15827,7 @@ async function spawnTicketWorker(context, ctx, ticket) {
15489
15827
  if (nowMs - lastStatusAtMs < AGENT_WORKER_STATUS_THROTTLE_MS) continue;
15490
15828
  lastStatusAtMs = nowMs;
15491
15829
  recordWorker(agentId, ticketId, { activity: summary, activityAt: new Date().toISOString() });
15830
+ noteWorkerLiveness(ticketId, { lastProgress: String(summary).slice(0, 160), lastProgressAtMs: nowMs });
15492
15831
  postAgentStatus(context, agentId, {
15493
15832
  state: 'working', ticketId, activity: summary, step: 'worker:' + agentType
15494
15833
  });
@@ -15503,12 +15842,23 @@ async function spawnTicketWorker(context, ctx, ticket) {
15503
15842
 
15504
15843
  const done = new Promise((resolve) => {
15505
15844
  child.on('error', (err) => {
15845
+ WORKER_LIVENESS.delete(String(ticketId));
15506
15846
  appendGlobalActivityLog('agent', 'worker.spawn_failed',
15507
15847
  { agentId, ticketId, agentType, error: describeError(err) }, 'error');
15508
15848
  resolve({ ticketId, ok: false, reason: 'spawn_failed', message: describeError(err) });
15509
15849
  });
15510
15850
  child.on('close', async (code, signal) => {
15511
15851
  try { logStream.end(); } catch (_) { /* nothing to do */ }
15852
+ // The attempt is over: its credential must not outlive it (a late or orphaned child cannot keep acting).
15853
+ WORKER_LIVENESS.delete(String(ticketId));
15854
+ // The worker may have exited with receipts still queued in its checkout: a worker exit must not erase
15855
+ // unsent evidence. Drained with the SUPERVISOR's session (the worker credential is about to be revoked).
15856
+ if (workingDirectory) {
15857
+ try {
15858
+ await drainEvidenceOutbox(workingDirectory, { ...context, dataMode: context.dataMode || ctx.dataMode }, agentId);
15859
+ } catch (_) { /* stays queued for the next drain */ }
15860
+ }
15861
+ await revokeWorkerCredential(context, workerCredential);
15512
15862
  const ok = code === 0 && !signal;
15513
15863
  appendGlobalActivityLog('agent', 'worker.finished',
15514
15864
  { agentId, ticketId, agentType, code, signal, attempt, logFile }, ok ? 'info' : 'error');
@@ -15517,6 +15867,8 @@ async function spawnTicketWorker(context, ctx, ticket) {
15517
15867
  // zero having done nothing. So the TICKET decides — unclaim always, and only give up (release
15518
15868
  // it back to the queue) once it has genuinely failed enough times and is still not terminal.
15519
15869
  const giveUp = !ok && attempt >= AGENT_WORKER_MAX_ATTEMPTS;
15870
+ // Spend first, while this run still holds the claim it is reported against.
15871
+ await reportWorkerCost(context, agentId, { ticketId, accountId: ticket.accountId }, logFile, ctx.runId, agentType);
15520
15872
  const result = await releaseTicketClaim(context, agentId, { ticketId, accountId: ticket.accountId }, {
15521
15873
  runId: ctx.runId,
15522
15874
  outcome: ok ? 'completed' : (signal ? 'killed' : 'failed'),
@@ -15662,6 +16014,7 @@ async function agentHeartbeatDaemonCommand(flags) {
15662
16014
  claim: inFlight.size < ctx.maxConcurrent,
15663
16015
  runId: ctx.runId,
15664
16016
  activeTickets,
16017
+ attempts: workerLivenessFor(activeTickets),
15665
16018
  maxConcurrent: ctx.maxConcurrent,
15666
16019
  // Declared so the phase the platform derives already accounts for an operator who asked for a
15667
16020
  // read-only agent. Without it the server would hand out an edit lease this session will never
@@ -15972,6 +16325,8 @@ async function ticketCommand(flags, subcommand) {
15972
16325
 
15973
16326
  const local = ownLocalAgent(flags);
15974
16327
  const context = agentSessionFor(flags, local);
16328
+ // Publish any passive receipts this checkout queued, before the verb that may hand the work to someone else.
16329
+ try { await drainEvidenceOutbox(process.cwd(), context); } catch (_) { /* never blocks the verb */ }
15975
16330
 
15976
16331
  // Routing lives on its own endpoint (the operator surface the dashboard and any Embeddable share),
15977
16332
  // so it is dispatched here rather than folded into the lifecycle switch.
@@ -16015,6 +16370,13 @@ async function ticketCommand(flags, subcommand) {
16015
16370
  route: flags.route === 'false' ? false : undefined,
16016
16371
  details: flags.details,
16017
16372
  nextStep: flags['next-step'],
16373
+ // The structured handoff checkpoint (durable references, never local paths) and the revision a
16374
+ // decision was made from: a stale one is refused with code stale_revision instead of being rebased.
16375
+ artifactRefs: csvFlag(flags['artifact-refs'] || flags.artifactRefs),
16376
+ evidenceRefs: csvFlag(flags['evidence-refs'] || flags.evidenceRefs),
16377
+ waitingOn: csvFlag(flags['waiting-on'] || flags.waitingOn),
16378
+ requiredCapabilities: csvFlag(flags.needs || flags['required-capabilities']),
16379
+ expectedRevision: flags['expected-revision'] !== undefined ? Number(flags['expected-revision']) : undefined,
16018
16380
  category: flags.category,
16019
16381
  // Clearing a planning slot has to be expressible, and `||` could not express it: an empty value is
16020
16382
  // falsy, so it collapsed to undefined, JSON.stringify dropped the key, and the server was never told.
@@ -16063,7 +16425,24 @@ async function ticketCommand(flags, subcommand) {
16063
16425
  approvers: flags.approvers ? String(flags.approvers).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
16064
16426
  reviewers: flags.reviewers ? String(flags.reviewers).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
16065
16427
  expiresAt: flags['expires-at'] || undefined,
16428
+ // N-of-M: how many distinct people must approve before the request is approved.
16429
+ minApprovals: flags['min-approvals'] ? Number(flags['min-approvals']) : undefined,
16066
16430
  delivery: flags.delivery || undefined,
16431
+ // `wake`: a durable timer. `--at` an instant, `--in` minutes, `--cancel` to clear it.
16432
+ wakeAt: action === 'wake' ? (flags.at || undefined) : undefined,
16433
+ wakeInMinutes: action === 'wake' && flags.in ? Number(flags.in) : undefined,
16434
+ cancel: action === 'wake' && flagEnabled(flags.cancel) ? true : undefined,
16435
+ // `depends-on`: the tickets this one waits on. The flag with no value clears them.
16436
+ dependsOn: action === 'depends-on'
16437
+ ? (flags.tickets === true || flags.tickets === '' ? [] : (flags.tickets ? String(flags.tickets).split(',').map((v) => v.trim()).filter(Boolean) : undefined))
16438
+ : undefined,
16439
+ // `cost`: what a run cost, reported by whoever can see it (a launcher, a person).
16440
+ usd: action === 'cost' && flags.usd !== undefined ? Number(flags.usd) : undefined,
16441
+ inputTokens: action === 'cost' && flags['input-tokens'] ? Number(flags['input-tokens']) : undefined,
16442
+ outputTokens: action === 'cost' && flags['output-tokens'] ? Number(flags['output-tokens']) : undefined,
16443
+ model: action === 'cost' ? (flags.model || undefined) : undefined,
16444
+ provider: action === 'cost' ? (flags.provider || undefined) : undefined,
16445
+ costRunId: action === 'cost' ? (flags['cost-run-id'] || undefined) : undefined,
16067
16446
  // The revision a call acts on or a decision looked at: a patch digest, a staged snapshot, a version.
16068
16447
  revision: flags.revision || undefined,
16069
16448
  note: flags.note,
@@ -16780,7 +17159,7 @@ function printTicketHelp() {
16780
17159
  console.log(' ask --question "..." [--context "..."] [--to WHO] [--visibility internal]');
16781
17160
  console.log(' Put a question to a human and park. Use it when the CHOICE is not yours. Recorded as');
16782
17161
  console.log(' request R<n>; answering it resumes the work even if no worker is online right now.');
16783
- console.log(' request-approval --for ACTION [--revision R | --delivery ID] [--approvers a@x,b@y] [--summary "..."]');
17162
+ console.log(' request-approval --for ACTION [--revision R | --delivery ID] [--approvers a@x,b@y] [--min-approvals N] [--summary "..."]');
16784
17163
  console.log(' Ask a PERSON to authorize one action on one revision (promote a patch, send a message).');
16785
17164
  console.log(' An answer is not an approval: only an authenticated person can decide it, never an');
16786
17165
  console.log(' agent run, and a changed revision needs a new approval.');
@@ -16791,10 +17170,25 @@ function printTicketHelp() {
16791
17170
  console.log(' cancel-request --request R2 [--reason "..."]');
16792
17171
  console.log(' handoff --summary "what the next worker should do" [--workstream review] [--exclude]');
16793
17172
  console.log(' [--review-action promote --revision R] [--next-step "..."] [--findings "a|b"]');
17173
+ console.log(' [--artifact-refs a,b] [--evidence-refs e1,e2] [--waiting-on X] [--needs capability]');
16794
17174
  console.log(' Hand the next step to another session (queued if nobody is online). --exclude keeps');
16795
17175
  console.log(' YOU off it — use it for independent review. Your exit will not erase this handoff.');
17176
+ console.log(' The checkpoint (findings, next step, refs, waiting-on, needs) is in the next worker\'s brief.');
17177
+ console.log(' --expected-revision N (handoff, decide, complete): refuse with stale_revision if the ticket moved');
17178
+ console.log(' past revision N since you read it, instead of merging your decision over newer changes.');
16796
17179
  console.log(' continue [--reason requested] Queue the ticket for the automation to work again.');
16797
- console.log(' reset-budget OPERATOR: restore a ticket\'s run budget after looking at it.');
17180
+ console.log(' cancel --reason "..." [--expected-revision N] The outcome is no longer wanted: closed with disposition');
17181
+ console.log(' cancelled; history kept; open requests cancelled; anything already landed or sent is NOT undone.');
17182
+ console.log(' redeliver-events OPERATOR: re-dispatch undelivered lifecycle events (deduplicated by eventId).');
17183
+ console.log(' reset-budget OPERATOR: restore a ticket\'s run and spend budget after looking at it.');
17184
+ console.log(' wake --in MINUTES | --at ISO-TIME [--reason "..."] | wake --cancel');
17185
+ console.log(' A durable timer: when it is due the ticket is runnable work again (reason `timer`) and');
17186
+ console.log(' the run that takes it is told why. Use it for "look again after X", never sleep-and-poll.');
17187
+ console.log(' depends-on --tickets 101,102 This ticket waits on those; the sweep will not start it until');
17188
+ console.log(' every one is resolved or closed. `--tickets` with no value clears it.');
17189
+ console.log(' cost --usd 1.25 [--input-tokens N] [--output-tokens N] [--model M] [--cost-run-id R]');
17190
+ console.log(' Record what a run cost. One report per run (a second replaces the first); the account\'s');
17191
+ console.log(' supportRouting.maxTicketSpendUsd then stops automatic starts.');
16798
17192
  console.log(' answer --message "..." [--route false] (alias: reply)');
16799
17193
  console.log(' Answer the open question a previous run asked. By default this re-routes the ticket,');
16800
17194
  console.log(' so a fresh worker resumes with your answer already in its brief. This is NOT how you');
@@ -16962,6 +17356,113 @@ function usageListFlag(value) {
16962
17356
  return items.length ? items : undefined;
16963
17357
  }
16964
17358
 
17359
+ /** `remits-cli capabilities` — the platform's collaboration catalog (features, verbs, refusal codes). */
17360
+ async function capabilitiesCommand(flags) {
17361
+ const cwd = process.cwd();
17362
+ let baseUrl = flags['base-url'];
17363
+ if (!baseUrl) {
17364
+ try { baseUrl = resolveSessionContext(cwd, flags).session.baseUrl; } catch (_) { /* no session is fine */ }
17365
+ }
17366
+ baseUrl = baseUrl || DEFAULT_BASE_URL;
17367
+ let response;
17368
+ try {
17369
+ response = await axios.get(baseUrl.replace(/\/$/, '') + '/cli/capabilities', { timeout: 15000, headers: cliRequestHeaders(null) })
17370
+ .then((r) => r.data);
17371
+ } catch (err) {
17372
+ if (err && err.response && err.response.status === 404) {
17373
+ response = { success: false, code: 'unsupported',
17374
+ message: 'This platform predates the capability catalog; it supports none of the negotiated collaboration features.' };
17375
+ } else {
17376
+ throw err;
17377
+ }
17378
+ }
17379
+ if (flagEnabled(flags.json)) {
17380
+ console.log(JSON.stringify(response, null, 2));
17381
+ return response;
17382
+ }
17383
+ if (!response || response.success !== true) {
17384
+ console.log(response && response.message ? response.message : 'No capability catalog available.');
17385
+ return response;
17386
+ }
17387
+ console.log('Collaboration capabilities — ' + baseUrl + ' (protocol ' + response.protocolVersion + ')');
17388
+ console.log('');
17389
+ console.log('Features:');
17390
+ Object.entries(response.featureDetails || {}).forEach(([name, detail]) => console.log(' ' + name.padEnd(24) + ' ' + detail));
17391
+ console.log('');
17392
+ console.log('Verbs:');
17393
+ (response.verbs || []).forEach((v) => console.log(' ' + String(v.verb).padEnd(30) + ' ' + (v.sideEffect || '') +
17394
+ (v.refusals && v.refusals.length ? ' [refusals: ' + v.refusals.join(', ') + ']' : '')));
17395
+ console.log('');
17396
+ console.log('Refusal codes:');
17397
+ Object.entries(response.codes || {}).forEach(([code, meaning]) => console.log(' ' + code.padEnd(20) + ' ' + meaning));
17398
+ console.log('');
17399
+ console.log(response.note);
17400
+ return response;
17401
+ }
17402
+
17403
+ /**
17404
+ * `remits-cli policy explain` — what the account's deterministic rules would decide for a proposed action,
17405
+ * and why, WITHOUT applying it. The verb re-evaluates when it actually runs; this is the preview.
17406
+ */
17407
+ async function policyExplainCommand(flags) {
17408
+ const cwd = process.cwd();
17409
+ ensureLocalState(cwd);
17410
+ const sessionContext = resolveSessionContext(cwd, flags);
17411
+ const { session, accountId } = sessionContext;
17412
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
17413
+ const api = buildAxios(baseUrl, session.token, 60000);
17414
+ const components = csvFlag(flags.components || flags.component);
17415
+ const response = await loggedPost(api, cwd, '/cli/policyExplain', {
17416
+ // The checkout's branch and staging workspace: a person explaining their STAGED process draft is answered
17417
+ // from it (the authoring lane shows staged prompts only when the call names its branch).
17418
+ ...runContextPayload(flags),
17419
+ token: session.token,
17420
+ accountId: flags['target-account-id'] ? Number(flags['target-account-id']) : accountId,
17421
+ dataMode: resolveDataMode(flags, session),
17422
+ point: flags.point,
17423
+ workstream: flags.workstream,
17424
+ capabilities: csvFlag(flags.capabilities || flags.capability) || undefined,
17425
+ actorKind: flags.as || flags['actor-kind'],
17426
+ acknowledge: flags.acknowledge,
17427
+ components: components ? components.map((entry) => {
17428
+ const [type, name, category] = String(entry).split(':');
17429
+ return { type, name, category };
17430
+ }) : undefined
17431
+ }).then((r) => r.data).catch((err) => {
17432
+ if (err && err.response && err.response.data) return err.response.data;
17433
+ throw err;
17434
+ });
17435
+ if (flagEnabled(flags.json)) {
17436
+ console.log(JSON.stringify(response, null, 2));
17437
+ return response;
17438
+ }
17439
+ if (!response || response.success !== true) {
17440
+ throw new Error((response && response.message) || 'Policy explanation failed');
17441
+ }
17442
+ const decision = response.decision || {};
17443
+ console.log('Policy at "' + response.point + '" on account ' + response.accountId +
17444
+ (response.workstream ? ' (workstream ' + response.workstream + ')' : '') + ' — ' + response.dataMode + ' lane');
17445
+ console.log(' as: ' + ((response.actor && response.actor.kind) || '?') +
17446
+ ((response.actor && response.actor.evidence && response.actor.evidence.length) ? ' (' + response.actor.evidence.join(', ') + ')' : ''));
17447
+ console.log(' decision: ' + (decision.allowed ? 'ALLOWED' : 'REFUSED (' + (decision.code || 'policy_denied') + ')'));
17448
+ if (decision.message) console.log(' ' + decision.message);
17449
+ (decision.warnings || []).forEach((w) => console.log(' warn ' + w.ruleId + ': ' + (w.message || '')));
17450
+ const relevant = (response.rules || []).filter((r) => r.appliesAtPoint || r.fires);
17451
+ if (relevant.length) {
17452
+ console.log(' rules that apply at this point:');
17453
+ relevant.forEach((r) => console.log(' ' + (r.fires ? '*' : ' ') + ' [' + r.grade + '] ' + r.id +
17454
+ (r.final ? ' (final)' : '') + ' from ' + (r.source || '?') +
17455
+ (r.distance !== undefined ? ' (distance ' + r.distance + ')' : '')));
17456
+ } else {
17457
+ console.log(' no rule applies at this point.');
17458
+ }
17459
+ const eff = response.effectivePolicy || {};
17460
+ if (eff.unavailable) console.log(' POLICY UNAVAILABLE: mandatory decisions are refused until it can be read.');
17461
+ if (eff.error) console.log(' policy problem: ' + eff.error);
17462
+ console.log(' ' + response.note);
17463
+ return response;
17464
+ }
17465
+
16965
17466
  async function usageCommand(flags) {
16966
17467
  const cwd = process.cwd();
16967
17468
  ensureLocalState(cwd);
@@ -17419,9 +17920,29 @@ function listWorkstreamIndexes(cwd) {
17419
17920
  .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
17420
17921
  }
17421
17922
 
17923
+ function evidenceOutboxPending(cwd) {
17924
+ try {
17925
+ const file = evidenceOutboxFile(cwd);
17926
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).length : 0;
17927
+ } catch (_) {
17928
+ return 0;
17929
+ }
17930
+ }
17931
+
17422
17932
  async function evidenceCommand(flags) {
17423
17933
  const cwd = process.cwd();
17424
17934
  ensureLocalState(cwd);
17935
+ if (flags._ && flags._[1] === 'flush') {
17936
+ const context = agentSessionFor(flags, ownLocalAgent(flags));
17937
+ const result = await drainEvidenceOutbox(cwd, context);
17938
+ if (flagEnabled(flags.json)) {
17939
+ console.log(JSON.stringify({ success: true, ...result }, null, 2));
17940
+ } else {
17941
+ console.log('Published ' + result.sent + ' receipt(s)' + (result.duplicate ? ', ' + result.duplicate + ' already recorded' : '') +
17942
+ (result.rejected ? ', ' + result.rejected + ' rejected' : '') + (result.kept ? '; ' + result.kept + ' still queued' : '') + '.');
17943
+ }
17944
+ return result;
17945
+ }
17425
17946
  const limit = Math.max(1, Math.min(parseInt(flags.limit || flags.max || '40', 10) || 40, 500));
17426
17947
  const entries = readEvidenceTrail(cwd, limit);
17427
17948
  if (flagEnabled(flags.json)) {
@@ -17430,6 +17951,10 @@ async function evidenceCommand(flags) {
17430
17951
  }
17431
17952
  const paths = localStatePaths(cwd);
17432
17953
  console.log('Evidence trail for actor ' + paths.actor.id + ' (' + paths.evidenceFile + ')');
17954
+ const pending = evidenceOutboxPending(cwd);
17955
+ if (pending) {
17956
+ console.log(pending + ' receipt(s) queued for the work item and not yet published — remits-cli evidence flush');
17957
+ }
17433
17958
  if (!entries.length) {
17434
17959
  console.log('Nothing recorded yet. Every stage, test, token, tool and sync appends one world-stamped line here,');
17435
17960
  console.log('with no envelope and nothing to start.');
@@ -17531,6 +18056,35 @@ async function workstreamCommand(flags, subcommand) {
17531
18056
  * and the one an operator asks when a queue is not moving. Agents, leases and tickets were each readable
17532
18057
  * on their own and never together, so every caller invented its own join and they disagreed.
17533
18058
  */
18059
+ /**
18060
+ * One line from the platform's CollaborationActivity projection. Null when an older server sent none.
18061
+ * Live work and stored facts are named separately, and unknown coverage is said, never shown as zero.
18062
+ */
18063
+ function describeActivityHeadline(activity) {
18064
+ if (!activity || typeof activity !== 'object') return null;
18065
+ const parts = [];
18066
+ const coverage = activity.coverage || {};
18067
+ if (coverage.registry === 'unavailable') {
18068
+ parts.push('live participants unknown');
18069
+ } else {
18070
+ const running = (activity.attempts && activity.attempts.running) || 0;
18071
+ const online = (activity.participants && activity.participants.online) || 0;
18072
+ parts.push('live: ' + running + ' attempt(s) running, ' + online + ' participant(s) online');
18073
+ }
18074
+ if (coverage.staging === 'unavailable') {
18075
+ parts.push('staging unknown');
18076
+ } else if (activity.staged && activity.staged.laneCount) {
18077
+ const changes = activity.changes || {};
18078
+ parts.push('stored: ' + (changes.componentCount != null ? changes.componentCount + ' last-reported change(s), ' : '') +
18079
+ activity.staged.entryCount + ' staged entr(ies) in ' + activity.staged.laneCount + ' lane(s)' +
18080
+ (changes.asOf ? ' (as of ' + changes.asOf + ')' : ''));
18081
+ }
18082
+ if (Array.isArray(activity.offlineAuthors) && activity.offlineAuthors.length) {
18083
+ parts.push('staged by offline author(s): ' + activity.offlineAuthors.join(', '));
18084
+ }
18085
+ return ' ' + parts.join(' · ');
18086
+ }
18087
+
17534
18088
  async function agentWorkMapCommand(flags) {
17535
18089
  const agentId = resolveAgentId(flags, { optional: true });
17536
18090
  const local = agentId ? (listLocalAgents().find((entry) => entry.agentId === agentId) || {}) : {};
@@ -17557,12 +18111,19 @@ async function agentWorkMapCommand(flags) {
17557
18111
  return;
17558
18112
  }
17559
18113
  console.log('Work map — ' + context.baseUrl + ' · ' + response.dataMode + ' lane');
18114
+ // The platform's activity projection (the same one the account console renders): live work is the
18115
+ // running/online counts; last-reported changes and the staged overlay are stored facts with an as-of.
18116
+ const activityLine = describeActivityHeadline(response && response.activity);
18117
+ if (activityLine) console.log(activityLine);
17560
18118
  console.log('');
17561
18119
  for (const repo of repositories) {
17562
18120
  const lease = repo.editLease;
17563
18121
  console.log(' ' + repo.accountName + ' (' + repo.accountId + ')' +
17564
18122
  (repo.accountType ? ' ' + repo.accountType : ''));
17565
- if (lease) {
18123
+ if (lease && lease.unavailable) {
18124
+ // UNKNOWN is its own state: nobody may edit, and nobody is named as the holder.
18125
+ console.log(' EDIT LEASE unknown (coordination store unavailable) — investigate read-only');
18126
+ } else if (lease) {
17566
18127
  const who = lease.agentLabel || lease.agentId;
17567
18128
  console.log(' EDIT LEASE ' + who +
17568
18129
  (lease.ticketId ? ' ticket ' + lease.ticketId : '') +
@@ -18638,7 +19199,7 @@ function printComponentsHelp(subcommand) {
18638
19199
  }
18639
19200
 
18640
19201
  function printTestHelp() {
18641
- console.log('Usage: remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none] [--label TEXT] [--param name=value ...] [--repeat N] [--parallel N] [--json]');
19202
+ console.log('Usage: remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b" | --failed] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none] [--label TEXT] [--param name=value ...] [--repeat N] [--parallel N] [--json]');
18642
19203
  console.log(' remits-cli test status --task-id <taskId> [--base-url URL] [--account-id ID] [--branch stagingScope] [--data-mode test|prod] [--json]');
18643
19204
  console.log(' remits-cli test runs [--test <id|name>] [--limit 20] [--compare] [--json] # durable run history');
18644
19205
  console.log(' remits-cli test compare --base <taskId> --head <taskId> [--json] # per-case outcome/cost deltas');
@@ -18647,11 +19208,15 @@ function printTestHelp() {
18647
19208
  console.log('Examples:');
18648
19209
  console.log(' remits-cli test run --test 11');
18649
19210
  console.log(' remits-cli test run --test "Merchant Statements" --names "managed account case"');
19211
+ console.log(' remits-cli test run --test "Merchant Statements" --failed');
18650
19212
  console.log(' remits-cli test status --task-id 7a1b...');
18651
19213
  console.log('');
18652
19214
  console.log('Notes:');
18653
19215
  console.log(' --names is delimited by "|" (comma also works when no "|" is present), and may be repeated.');
18654
19216
  console.log(' A selector that matches no case fails the run rather than reporting 0 passed / 0 failed.');
19217
+ console.log(' --failed re-runs only the cases that did not pass in your last finished run of this suite on');
19218
+ console.log(' this git branch and staging workspace. Iterate with --failed / --names; run the whole');
19219
+ console.log(' suite once before landing.');
18655
19220
  console.log(' --as-account changes the execution account so subscriber branch edges apply.');
18656
19221
  console.log(' --variant-branch <name> probes a committed variant branch; use "none" or "trunk"');
18657
19222
  console.log(' to force production/subscription semantics from a variant checkout.');
@@ -18836,6 +19401,7 @@ async function main() {
18836
19401
  migrateSessionIfNeeded();
18837
19402
  const originalArgv = process.argv.slice(2);
18838
19403
  const args = parseArgs(originalArgv);
19404
+ requestIdentityFlags = args;
18839
19405
  configureLocalActor(args);
18840
19406
  const [command, subcommand] = args._;
18841
19407
  const wantsHelp = args.help === true || subcommand === 'help' || subcommand === '--help' || args._.includes('--help');
@@ -18851,7 +19417,7 @@ async function main() {
18851
19417
  requireAccountRepoContext(process.cwd(), flags, 'ai ' + subcommand, context);
18852
19418
  await localAi.command({
18853
19419
  subcommand, flags, context, root: SESSION_DIR, axios, Client, WebSocket,
18854
- defaultAgent: getPreferredAgent()
19420
+ defaultAgent: getPreferredAgent(), requestHeaders: cliRequestHeaders
18855
19421
  });
18856
19422
  return;
18857
19423
  }
@@ -18910,7 +19476,9 @@ async function main() {
18910
19476
 
18911
19477
  if (!command || command === 'help' || command === '--help') {
18912
19478
  console.log('Usage: remits-cli <command>');
18913
- console.log('Global options: --no-auto-update, --local-agent NAME');
19479
+ console.log('Global options: --no-auto-update, --local-agent NAME, --agent-id ID');
19480
+ console.log(' --agent-id / REMITS_AGENT_ID declares the agent acting for the authenticated user on CLI requests.');
19481
+ console.log(' --local-agent names only the local log folder; it does not declare an agent to the platform.');
18914
19482
  console.log(' remits-cli auth [--base-url URL] [--account-id ID] [--port 8765] [--data-mode test|prod]');
18915
19483
  console.log(' remits-cli sessions [list|remove] [--account-id ID] [--base-url URL] [--data-mode test|prod]');
18916
19484
  console.log(' remits-cli config [set] [--agent claude|codex|gemini]');
@@ -18924,6 +19492,8 @@ async function main() {
18924
19492
  console.log(' remits-cli activity inspect [--account-id ID] [--scope self|children|related] [--workspace NAME] [--workstream ID] [--user-email E] [--summary|--compact] [--json]');
18925
19493
  console.log(' remits-cli workstream status [--workstream ID|--workspace NAME] [--json]');
18926
19494
  console.log(' remits-cli usage --from DATE [--to DATE] [--by hour|day|month] [--group-by account,sku] [--capabilities ai,document_store] [--json]');
19495
+ console.log(' remits-cli capabilities [--json] Collaboration features, verbs and refusal codes this platform supports');
19496
+ console.log(' remits-cli policy explain --point commit [--as agent] What the account rules would decide, and why (nothing applied)');
18927
19497
  console.log(' remits-cli agent release # stop receiving tickets now');
18928
19498
  console.log(' remits-cli start [--foreground true] [--port 8787] # human dashboard in the browser');
18929
19499
  console.log(' remits-cli stop');
@@ -18955,7 +19525,7 @@ async function main() {
18955
19525
  console.log(' remits-cli components branch <name> --subscribe <accountId> [--dry-run] [--confirm-primary-edge]');
18956
19526
  console.log(' remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk');
18957
19527
  console.log(' remits-cli components branch <name> --retire [--force] # delete the branch\'s overlays');
18958
- console.log(' remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none] [--label TEXT] [--param name=value ...] [--repeat N] [--parallel N] [--json]');
19528
+ console.log(' remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b" | --failed] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none] [--label TEXT] [--param name=value ...] [--repeat N] [--parallel N] [--json]');
18959
19529
  console.log(' remits-cli test runs [--test <id|name>] [--compare] # durable run history; compare latest two');
18960
19530
  console.log(' remits-cli test compare --base <taskId> --head <taskId>');
18961
19531
  console.log(' remits-cli corpus import --manifest corpus-manifest.json # seed a test corpus: cases + artifacts');
@@ -19186,6 +19756,33 @@ async function main() {
19186
19756
  throw new Error('Unknown activity subcommand: ' + subcommand);
19187
19757
  }
19188
19758
 
19759
+ if (command === 'capabilities') {
19760
+ if (wantsHelp) {
19761
+ console.log('Usage: remits-cli capabilities [--json] [--base-url URL]');
19762
+ console.log('');
19763
+ console.log('What this platform build supports for collaboration: negotiated features, the stable verbs and');
19764
+ console.log('how each settles, and what every refusal code means. Read-only; needs no special access.');
19765
+ return;
19766
+ }
19767
+ await capabilitiesCommand(args);
19768
+ return;
19769
+ }
19770
+
19771
+ if (command === 'policy') {
19772
+ if (wantsHelp || subcommand !== 'explain') {
19773
+ console.log('Usage: remits-cli policy explain --point <commit|land|stage|complete|deliver|...> [--workstream W]');
19774
+ console.log(' [--capabilities a,b] [--as human|agent] [--components type:name[:category],...]');
19775
+ console.log(' [--target-account-id ID] [--json]');
19776
+ console.log('');
19777
+ console.log("What this account's remits-policy rules would decide for that action, which rules apply, where");
19778
+ console.log('they come from (document, account distance, final) — without applying anything. The real verb');
19779
+ console.log('re-checks the policy in force when it runs.');
19780
+ return;
19781
+ }
19782
+ await policyExplainCommand(args);
19783
+ return;
19784
+ }
19785
+
19189
19786
  if (command === 'usage') {
19190
19787
  if (wantsHelp) {
19191
19788
  console.log('Usage: remits-cli usage --from DATE [--to DATE] [--timezone ZONE] [--by hour|day|month]');
@@ -19205,7 +19802,7 @@ async function main() {
19205
19802
 
19206
19803
  if (command === 'evidence') {
19207
19804
  if (wantsHelp) {
19208
- console.log('Usage: remits-cli evidence [--limit N] [--json]');
19805
+ console.log('Usage: remits-cli evidence [--limit N] [--json] | remits-cli evidence flush');
19209
19806
  console.log('');
19210
19807
  console.log('What you have actually run, and in which world. Every stage, test, token, tool and sync');
19211
19808
  console.log('appends one world-stamped line automatically - there is nothing to start and nothing to');
@@ -19213,6 +19810,9 @@ async function main() {
19213
19810
  console.log('');
19214
19811
  console.log('This is per-actor, so agents sharing a checkout never read each other\'s trail.');
19215
19812
  console.log('Use `remits-cli verify` only when someone needs a checkable VERDICT on top of this.');
19813
+ console.log('');
19814
+ console.log('Inside a work attempt (REMITS_SUPPORT_TICKET_ID), each line is also queued and published to the');
19815
+ console.log('work item as a passive receipt, so a successor elsewhere can read it. `flush` publishes now.');
19216
19816
  return;
19217
19817
  }
19218
19818
  await evidenceCommand(args);