@remits/remits-cli 0.1.168 → 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
@@ -6,16 +6,16 @@
6
6
  - L22 Runtime Bootstrap And Shared State
7
7
  - L128 Sessions, Account Resolution, And Production Guards
8
8
  - L634 Local State, Workspaces, And Verification Evidence
9
- - L2672 Account Repos, Guide Sync, And Platform Repo
10
- - L3571 Component Discovery And HTTP Logging
11
- - L4350 Skill Delivery And TOC Resolution
12
- - L4665 Auth And Component Staging
13
- - L5301 Component Summaries, Status, And Sync Gates
14
- - L7625 Branches, Promotion, Commit, And Test Runs
15
- - L9889 Tokens, Tools, Verification, And Config
16
- - L11406 Service Dashboard And WebSocket Listener
17
- - L13916 Agent And Ticket Workflows
18
- - L18429 Help, Auto Update, And Command Dispatch
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
  /*
@@ -2097,6 +2097,7 @@ function recordEvidenceEntry(cwd, packet, context = {}) {
2097
2097
  };
2098
2098
  fs.appendFileSync(paths.evidenceFile, JSON.stringify(entry) + '\n');
2099
2099
  trimEvidenceTrail(paths.evidenceFile);
2100
+ queueEvidenceReceipt(cwd, entry, context);
2100
2101
  return entry;
2101
2102
  } catch (_) {
2102
2103
  // A trail write must never fail the command it is describing.
@@ -2104,6 +2105,135 @@ function recordEvidenceEntry(cwd, packet, context = {}) {
2104
2105
  }
2105
2106
  }
2106
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
+
2107
2237
  function evidenceOutcome(row) {
2108
2238
  if (row && (row.pending === true || row.success === null)) return 'pending';
2109
2239
  return row && row.success === false ? 'failed' : 'ok';
@@ -4047,7 +4177,11 @@ function stageTimeoutMs(components, flags = {}) {
4047
4177
  }
4048
4178
 
4049
4179
  function cliRequestHeaders(token) {
4050
- const headers = token ? { Authorization: 'Bearer ' + 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 } : {};
4051
4185
  // The CLI version on EVERY request, from the one place every request is built. It used to travel only on
4052
4186
  // `agent register`, so the platform knew the version of the sessions that registered - and the agents that
4053
4187
  // loop hardest are exactly the ones that never do. Without it the platform cannot tell an agent that the
@@ -9131,6 +9265,27 @@ function testRunSummaryLines(status = {}) {
9131
9265
  return lines;
9132
9266
  }
9133
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
+
9134
9289
  // Per-case comparison of two runs of the same suite: which cases changed outcome, and how cost and duration
9135
9290
  // moved. The answer to "did this revision do better than the last one" without reconstructing either run.
9136
9291
  function compareTestRunResults(baseResult = {}, headResult = {}) {
@@ -9349,6 +9504,13 @@ function testRunLaunchLines(start = {}, runOptions = {}) {
9349
9504
  if (runOptions.label) lines.push('Label: ' + runOptions.label);
9350
9505
  if (runOptions.params) lines.push('Params: ' + Object.keys(runOptions.params).map((k) => k + '=' + JSON.stringify(runOptions.params[k])).join(' '));
9351
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
+ }
9352
9514
  if (start.runGroupId) {
9353
9515
  lines.push('Parallel: ' + (start.shardTaskIds || []).length + ' shards (' + (start.shardTaskIds || []).join(', ') + '), read as ONE run by ' + start.runGroupId);
9354
9516
  }
@@ -9383,6 +9545,10 @@ async function testCommand(flags) {
9383
9545
  ? []
9384
9546
  : (Array.isArray(flags.names) ? flags.names : [flags.names]);
9385
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
+ }
9386
9552
  // --label / --param / --repeat / --parallel: what this run varies, and how it is executed.
9387
9553
  const runOptions = testRunOptionsFromFlags(flags);
9388
9554
  const runGroupId = runOptions.parallel > 1 ? 'grp-' + crypto.randomUUID() : undefined;
@@ -9432,6 +9598,11 @@ async function testCommand(flags) {
9432
9598
  dataMode,
9433
9599
  dataModeSource,
9434
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,
9435
9606
  testId: /^\d+$/.test(String(testRef)) ? Number(testRef) : undefined,
9436
9607
  testName: /^\d+$/.test(String(testRef)) ? undefined : String(testRef),
9437
9608
  tests: names
@@ -9450,6 +9621,7 @@ async function testCommand(flags) {
9450
9621
  break;
9451
9622
  } catch (err) {
9452
9623
  const data = err && err.response && err.response.data;
9624
+ if (data && data.code === 'no_recorded_run') throw new Error(data.message);
9453
9625
  if (shardStarts.length && shardStartBusy(err) && Date.now() < shardWaitDeadline) {
9454
9626
  console.error('Shard ' + (shardStarts.length + 1) + ' of ' + runOptions.parallel + ' is waiting for a free suite slot ' +
9455
9627
  '(the platform runs a limited number of suites at once; the shards already running free them).');
@@ -9462,8 +9634,18 @@ async function testCommand(flags) {
9462
9634
  throw err;
9463
9635
  }
9464
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
+ }
9465
9647
  if (!shardStart.success) {
9466
- 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 +
9467
9649
  '; the shards already started keep running: ' + shardStarts.map((st) => st.taskId).join(', ') + ')' : ''));
9468
9650
  }
9469
9651
  shardStarts.push(shardStart);
@@ -9564,6 +9746,7 @@ async function testCommand(flags) {
9564
9746
  .forEach((line) => console.log(line));
9565
9747
  testRunSummaryLines(status).forEach((line) => console.log(line));
9566
9748
  testFailureGroupLines(status, { testRef }).forEach((line) => console.log(line));
9749
+ testRunPacingLines(status, { testRef, selected: names.length > 0 || rerunFailed }).forEach((line) => console.log(line));
9567
9750
  printTestRunPivots(status);
9568
9751
  // The whole run object used to be printed here as pretty JSON. One 92-case suite measured 220 KB,
9569
9752
  // most of it identifiers repeated once per case, spent on the turn with the least room left. It is
@@ -13975,7 +14158,7 @@ const AGENT_WORKER_MAX_LOG_BYTES = 8 * 1024 * 1024;
13975
14158
  *
13976
14159
  * `mode` is the autonomy bound, not a preference. `investigate` maps to each CLI's genuine
13977
14160
  * read-only mode so an operator can put a real boundary around a production support agent; `edit`
13978
- * 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).
13979
14162
  */
13980
14163
  const AGENT_LAUNCHERS = {
13981
14164
  codex: {
@@ -15354,6 +15537,22 @@ async function releaseTicketClaim(context, agentId, ticket, options = {}) {
15354
15537
  }
15355
15538
  }
15356
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
+
15357
15556
  async function postAgentStatus(context, agentId, update) {
15358
15557
  try {
15359
15558
  await buildAxios(context.baseUrl, context.session.token, 10000)
@@ -15370,6 +15569,39 @@ async function postAgentStatus(context, agentId, update) {
15370
15569
  * the same thing. Only the local supervisor can add machine-local repository hints; the worker owns the
15371
15570
  * final grounding step, including creating or choosing a git worktree before it edits.
15372
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
+
15373
15605
  async function spawnTicketWorker(context, ctx, ticket) {
15374
15606
  const { spawn } = require('child_process');
15375
15607
  const agentId = ctx.agentId;
@@ -15495,6 +15727,15 @@ async function spawnTicketWorker(context, ctx, ticket) {
15495
15727
  });
15496
15728
 
15497
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
+ });
15498
15739
  const child = spawn(launcher.binary, built.args, {
15499
15740
  cwd: workingDirectory || ctx.cwd,
15500
15741
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -15505,6 +15746,7 @@ async function spawnTicketWorker(context, ctx, ticket) {
15505
15746
  REMITS_AGENT_ID: agentId,
15506
15747
  REMITS_SUPPORT_TICKET_ID: ticketId,
15507
15748
  REMITS_WORKER_RUN_ID: runId,
15749
+ ...(workerCredential ? { REMITS_AGENT_TOKEN: workerCredential.credential } : {}),
15508
15750
  // Give the worker its OWN staging lane.
15509
15751
  //
15510
15752
  // The edit LEASE already serializes who may edit a repo, and this does not replace it — a lease
@@ -15566,8 +15808,10 @@ async function spawnTicketWorker(context, ctx, ticket) {
15566
15808
  let lastStatusAtMs = 0;
15567
15809
  let buffered = '';
15568
15810
 
15811
+ noteWorkerLiveness(ticketId, { runId, pid: child.pid, startedAtMs: Date.now() });
15569
15812
  const consume = (chunk) => {
15570
15813
  const text = chunk.toString();
15814
+ noteWorkerLiveness(ticketId, { lastOutputAtMs: Date.now() });
15571
15815
  if (logBytes < AGENT_WORKER_MAX_LOG_BYTES) {
15572
15816
  logBytes += text.length;
15573
15817
  logStream.write(text);
@@ -15583,6 +15827,7 @@ async function spawnTicketWorker(context, ctx, ticket) {
15583
15827
  if (nowMs - lastStatusAtMs < AGENT_WORKER_STATUS_THROTTLE_MS) continue;
15584
15828
  lastStatusAtMs = nowMs;
15585
15829
  recordWorker(agentId, ticketId, { activity: summary, activityAt: new Date().toISOString() });
15830
+ noteWorkerLiveness(ticketId, { lastProgress: String(summary).slice(0, 160), lastProgressAtMs: nowMs });
15586
15831
  postAgentStatus(context, agentId, {
15587
15832
  state: 'working', ticketId, activity: summary, step: 'worker:' + agentType
15588
15833
  });
@@ -15597,12 +15842,23 @@ async function spawnTicketWorker(context, ctx, ticket) {
15597
15842
 
15598
15843
  const done = new Promise((resolve) => {
15599
15844
  child.on('error', (err) => {
15845
+ WORKER_LIVENESS.delete(String(ticketId));
15600
15846
  appendGlobalActivityLog('agent', 'worker.spawn_failed',
15601
15847
  { agentId, ticketId, agentType, error: describeError(err) }, 'error');
15602
15848
  resolve({ ticketId, ok: false, reason: 'spawn_failed', message: describeError(err) });
15603
15849
  });
15604
15850
  child.on('close', async (code, signal) => {
15605
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);
15606
15862
  const ok = code === 0 && !signal;
15607
15863
  appendGlobalActivityLog('agent', 'worker.finished',
15608
15864
  { agentId, ticketId, agentType, code, signal, attempt, logFile }, ok ? 'info' : 'error');
@@ -15758,6 +16014,7 @@ async function agentHeartbeatDaemonCommand(flags) {
15758
16014
  claim: inFlight.size < ctx.maxConcurrent,
15759
16015
  runId: ctx.runId,
15760
16016
  activeTickets,
16017
+ attempts: workerLivenessFor(activeTickets),
15761
16018
  maxConcurrent: ctx.maxConcurrent,
15762
16019
  // Declared so the phase the platform derives already accounts for an operator who asked for a
15763
16020
  // read-only agent. Without it the server would hand out an edit lease this session will never
@@ -16068,6 +16325,8 @@ async function ticketCommand(flags, subcommand) {
16068
16325
 
16069
16326
  const local = ownLocalAgent(flags);
16070
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 */ }
16071
16330
 
16072
16331
  // Routing lives on its own endpoint (the operator surface the dashboard and any Embeddable share),
16073
16332
  // so it is dispatched here rather than folded into the lifecycle switch.
@@ -16111,6 +16370,13 @@ async function ticketCommand(flags, subcommand) {
16111
16370
  route: flags.route === 'false' ? false : undefined,
16112
16371
  details: flags.details,
16113
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,
16114
16380
  category: flags.category,
16115
16381
  // Clearing a planning slot has to be expressible, and `||` could not express it: an empty value is
16116
16382
  // falsy, so it collapsed to undefined, JSON.stringify dropped the key, and the server was never told.
@@ -16904,9 +17170,16 @@ function printTicketHelp() {
16904
17170
  console.log(' cancel-request --request R2 [--reason "..."]');
16905
17171
  console.log(' handoff --summary "what the next worker should do" [--workstream review] [--exclude]');
16906
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]');
16907
17174
  console.log(' Hand the next step to another session (queued if nobody is online). --exclude keeps');
16908
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.');
16909
17179
  console.log(' continue [--reason requested] Queue the ticket for the automation to work again.');
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).');
16910
17183
  console.log(' reset-budget OPERATOR: restore a ticket\'s run and spend budget after looking at it.');
16911
17184
  console.log(' wake --in MINUTES | --at ISO-TIME [--reason "..."] | wake --cancel');
16912
17185
  console.log(' A durable timer: when it is due the ticket is runnable work again (reason `timer`) and');
@@ -17083,6 +17356,113 @@ function usageListFlag(value) {
17083
17356
  return items.length ? items : undefined;
17084
17357
  }
17085
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
+
17086
17466
  async function usageCommand(flags) {
17087
17467
  const cwd = process.cwd();
17088
17468
  ensureLocalState(cwd);
@@ -17540,9 +17920,29 @@ function listWorkstreamIndexes(cwd) {
17540
17920
  .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
17541
17921
  }
17542
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
+
17543
17932
  async function evidenceCommand(flags) {
17544
17933
  const cwd = process.cwd();
17545
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
+ }
17546
17946
  const limit = Math.max(1, Math.min(parseInt(flags.limit || flags.max || '40', 10) || 40, 500));
17547
17947
  const entries = readEvidenceTrail(cwd, limit);
17548
17948
  if (flagEnabled(flags.json)) {
@@ -17551,6 +17951,10 @@ async function evidenceCommand(flags) {
17551
17951
  }
17552
17952
  const paths = localStatePaths(cwd);
17553
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
+ }
17554
17958
  if (!entries.length) {
17555
17959
  console.log('Nothing recorded yet. Every stage, test, token, tool and sync appends one world-stamped line here,');
17556
17960
  console.log('with no envelope and nothing to start.');
@@ -17652,6 +18056,35 @@ async function workstreamCommand(flags, subcommand) {
17652
18056
  * and the one an operator asks when a queue is not moving. Agents, leases and tickets were each readable
17653
18057
  * on their own and never together, so every caller invented its own join and they disagreed.
17654
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
+
17655
18088
  async function agentWorkMapCommand(flags) {
17656
18089
  const agentId = resolveAgentId(flags, { optional: true });
17657
18090
  const local = agentId ? (listLocalAgents().find((entry) => entry.agentId === agentId) || {}) : {};
@@ -17678,12 +18111,19 @@ async function agentWorkMapCommand(flags) {
17678
18111
  return;
17679
18112
  }
17680
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);
17681
18118
  console.log('');
17682
18119
  for (const repo of repositories) {
17683
18120
  const lease = repo.editLease;
17684
18121
  console.log(' ' + repo.accountName + ' (' + repo.accountId + ')' +
17685
18122
  (repo.accountType ? ' ' + repo.accountType : ''));
17686
- 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) {
17687
18127
  const who = lease.agentLabel || lease.agentId;
17688
18128
  console.log(' EDIT LEASE ' + who +
17689
18129
  (lease.ticketId ? ' ticket ' + lease.ticketId : '') +
@@ -18759,7 +19199,7 @@ function printComponentsHelp(subcommand) {
18759
19199
  }
18760
19200
 
18761
19201
  function printTestHelp() {
18762
- 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]');
18763
19203
  console.log(' remits-cli test status --task-id <taskId> [--base-url URL] [--account-id ID] [--branch stagingScope] [--data-mode test|prod] [--json]');
18764
19204
  console.log(' remits-cli test runs [--test <id|name>] [--limit 20] [--compare] [--json] # durable run history');
18765
19205
  console.log(' remits-cli test compare --base <taskId> --head <taskId> [--json] # per-case outcome/cost deltas');
@@ -18768,11 +19208,15 @@ function printTestHelp() {
18768
19208
  console.log('Examples:');
18769
19209
  console.log(' remits-cli test run --test 11');
18770
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');
18771
19212
  console.log(' remits-cli test status --task-id 7a1b...');
18772
19213
  console.log('');
18773
19214
  console.log('Notes:');
18774
19215
  console.log(' --names is delimited by "|" (comma also works when no "|" is present), and may be repeated.');
18775
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.');
18776
19220
  console.log(' --as-account changes the execution account so subscriber branch edges apply.');
18777
19221
  console.log(' --variant-branch <name> probes a committed variant branch; use "none" or "trunk"');
18778
19222
  console.log(' to force production/subscription semantics from a variant checkout.');
@@ -19048,6 +19492,8 @@ async function main() {
19048
19492
  console.log(' remits-cli activity inspect [--account-id ID] [--scope self|children|related] [--workspace NAME] [--workstream ID] [--user-email E] [--summary|--compact] [--json]');
19049
19493
  console.log(' remits-cli workstream status [--workstream ID|--workspace NAME] [--json]');
19050
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)');
19051
19497
  console.log(' remits-cli agent release # stop receiving tickets now');
19052
19498
  console.log(' remits-cli start [--foreground true] [--port 8787] # human dashboard in the browser');
19053
19499
  console.log(' remits-cli stop');
@@ -19079,7 +19525,7 @@ async function main() {
19079
19525
  console.log(' remits-cli components branch <name> --subscribe <accountId> [--dry-run] [--confirm-primary-edge]');
19080
19526
  console.log(' remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk');
19081
19527
  console.log(' remits-cli components branch <name> --retire [--force] # delete the branch\'s overlays');
19082
- 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]');
19083
19529
  console.log(' remits-cli test runs [--test <id|name>] [--compare] # durable run history; compare latest two');
19084
19530
  console.log(' remits-cli test compare --base <taskId> --head <taskId>');
19085
19531
  console.log(' remits-cli corpus import --manifest corpus-manifest.json # seed a test corpus: cases + artifacts');
@@ -19310,6 +19756,33 @@ async function main() {
19310
19756
  throw new Error('Unknown activity subcommand: ' + subcommand);
19311
19757
  }
19312
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
+
19313
19786
  if (command === 'usage') {
19314
19787
  if (wantsHelp) {
19315
19788
  console.log('Usage: remits-cli usage --from DATE [--to DATE] [--timezone ZONE] [--by hour|day|month]');
@@ -19329,7 +19802,7 @@ async function main() {
19329
19802
 
19330
19803
  if (command === 'evidence') {
19331
19804
  if (wantsHelp) {
19332
- console.log('Usage: remits-cli evidence [--limit N] [--json]');
19805
+ console.log('Usage: remits-cli evidence [--limit N] [--json] | remits-cli evidence flush');
19333
19806
  console.log('');
19334
19807
  console.log('What you have actually run, and in which world. Every stage, test, token, tool and sync');
19335
19808
  console.log('appends one world-stamped line automatically - there is nothing to start and nothing to');
@@ -19337,6 +19810,9 @@ async function main() {
19337
19810
  console.log('');
19338
19811
  console.log('This is per-actor, so agents sharing a checkout never read each other\'s trail.');
19339
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.');
19340
19816
  return;
19341
19817
  }
19342
19818
  await evidenceCommand(args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remits/remits-cli",
3
- "version": "0.1.168",
3
+ "version": "0.1.169",
4
4
  "description": "Local CLI for auth, component sync, and live test execution against Remits",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -64,6 +64,9 @@ reference named after it.
64
64
  - **edit → stage → run, every time.** The platform executes whatever is in the staging cache at the
65
65
  moment a run starts. Edit a file, run a test without `remits-cli components stage`, and the test runs
66
66
  the OLD code. This is the single most common mistake. (`development-loop.md`)
67
+ - **Iterate on the failing cases; run the whole suite once before landing.** Waiting on test runs is most
68
+ of a slow build loop, not staging. After each edit run `test run --test X --failed` (only what did not
69
+ pass last time) or `--names "a|b"`, not the full suite. (`development-loop.md`)
67
70
  - **Staging and sync fail early on broken runtime-compiled Groovy.** Stage validates changed source in the
68
71
  active lane; sync validates changed/new compiled components before writing DB rows or branch variants.
69
72
  Agent files are Utility components internally. A stage refused BY COMPILE VALIDATION still wrote the
@@ -100,9 +100,18 @@ whole point is that it needs no supervision. Following the transcript is the way
100
100
  strictly better than a terminal would be: the output is structured, so it can be read as events
101
101
  rather than scraped back out of ANSI text.
102
102
 
103
- **No worker ever commits.** The brief forbids `git commit`/`git push` and nothing in the supervisor
104
- runs git — a worker leaves its changes in the working tree and says so on the ticket, for a human to
105
- review.
103
+ **Whether a worker lands is the account's choice, not the supervisor's.** Nothing in the supervisor runs
104
+ git. The brief states the account's landing profile: by default a person lands (the worker leaves changes in
105
+ the working tree and says so on the ticket); an account with `supportPolicy.landing: agent` lets the worker
106
+ land with `remits-cli components commit`, which re-checks the account's rules when it runs.
107
+
108
+ **Each worker gets its own credential.** For every child the supervisor mints a credential bound to that
109
+ attempt (agent + ticket + run) and revokes it when the child exits; the child authenticates with it
110
+ (`REMITS_AGENT_TOKEN`). The platform therefore knows which participant is calling from the credential, so a
111
+ missing or wrong identity header cannot make a worker a human or another agent. Against an older platform
112
+ the worker simply uses the supervisor's session, as before. Each poll also reports, per running child, when
113
+ it last produced output and its last progress line — separate from the supervisor's own heartbeat, so
114
+ `agent map` can tell a connected supervisor from a child that is actually producing work.
106
115
 
107
116
  ### Resolving which agent a command means
108
117
 
@@ -218,13 +218,21 @@ remits-cli agent register [--label NAME] [--data-mode test|prod] [--account-id I
218
218
  remits-cli agent work [--wait SECONDS] [--json]
219
219
  remits-cli agent status [--state idle|working|paused] [--ticket ID] [--activity "..."] [--step "..."]
220
220
  remits-cli agent list [--account-id ID] [--json]
221
- remits-cli agent map [--account-ids 1,4] [--json] # who is EDITING which repository, from which checkout/branch/staging lane
221
+ remits-cli agent map [--account-ids 1,4] [--json] # who is EDITING which repository, from which checkout/branch/staging lane; first line = live work vs stored (staged/last-reported) facts
222
+ remits-cli capabilities [--json] # collaboration features this platform supports, verbs + how they settle, refusal codes
223
+ remits-cli policy explain --point commit|land|stage|complete|deliver|... [--as human|agent] [--workstream W] [--components type:name[:category],...] [--json] # what the account rules would decide, and why; nothing applied
224
+ remits-cli evidence [--limit N] [--json] | evidence flush # your world-stamped trail; inside a ticket attempt it is also published to the ticket as receipts
222
225
  remits-cli agent inspect [--account-id ID] [--scope self|children] [--user-id ID|--user-email EMAIL] [--limit N] [--json]
223
226
  remits-cli activity inspect [--account-id ID] [--scope self|children|related] [--workspace NAME] [--branch NAME] [--workstream ID] [--user-id ID|--user-email EMAIL] [--limit N] [--json]
224
227
  remits-cli workstream status [--workstream ID|--workspace NAME] [--json]
225
228
  remits-cli agent release
226
- 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 "..."]
227
- remits-cli ticket lease|unlease|force-unlease --ticket ID [--reason "..."] # force-unlease breaks SOMEBODY ELSE'S lease; operator only
229
+ remits-cli ticket read|accept|status|progress|complete|cancel|release|reopen|assign|planning --ticket ID [--status S] [--resolution "..."] [--reason "..."] [--summary "..."] [--category C] [--assignee EMAIL] [--workstream VALUE] [--planned-in VALUE] [--board-stage VALUE] [--rank N] [--size VALUE] [--blocked-by VALUE] [--notes "..."]
230
+ remits-cli ticket cancel --ticket ID --reason "..." [--expected-revision N] # close with disposition cancelled; history kept; stops worker
231
+ remits-cli ticket reset-budget --ticket ID # OPERATOR: restore run and spend budget after review
232
+ remits-cli ticket redeliver-events --ticket ID # OPERATOR: re-dispatch undelivered lifecycle events
233
+ remits-cli ticket lease|unlease|force-unlease --ticket ID [--reason "..."] # force-unlease breaks SOMEBODY ELSE'S lease; operator only. 503 code=unavailable = holder unknown: investigate, retry
234
+ remits-cli ticket handoff --ticket ID --summary "..." [--next-step "..."] [--findings "a|b"] [--artifact-refs a,b] [--evidence-refs r1,r2] [--waiting-on X] [--needs cap] [--workstream W] [--exclude] [--expected-revision N]
235
+ remits-cli ticket decide|complete ... [--expected-revision N] # refused with code=stale_revision (nothing written) if the ticket moved past N
228
236
  remits-cli ticket where --ticket ID # WHERE this work is: repo account, checkout, branch, staging lane, live lease + holder's location, claim, your phase
229
237
  remits-cli ticket ask --ticket ID --question "..." [--context "..."] [--to WHO] # park on a human decision
230
238
  remits-cli ticket answer --ticket ID --message "..." [--route false] # answer it (alias: reply); re-routes by default
@@ -265,7 +273,7 @@ remits-cli components branch <name> --copy-to <newBranch> [--dry-run] [--force]
265
273
  remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>] [--dry-run] [--confirm-primary-edge] # make an account resolve this branch
266
274
  remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk
267
275
  remits-cli components branch <name> --retire [--force] # delete the branch's overlays
268
- remits-cli test run --test <id|name> [--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]
276
+ remits-cli test run --test <id|name> [--candidate <laneContentHash>] [--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]
269
277
  remits-cli test status --task-id <taskId|groupId> [--branch <stagingScope>] [--data-mode test|prod] [--json] # falls back to the DURABLE run record once the live status expires
270
278
  remits-cli test runs [--test <id|name>] [--limit 20] [--compare] [--all-lanes] [--as-account <ID>] [--json] # durable run history: pass counts, live AI cost, lane, content hash
271
279
  remits-cli test compare --base <taskId> --head <taskId> [--json] # per-case improved / regressed / changed, cost and world deltas
@@ -281,6 +289,10 @@ remits-cli corpus consistency --corpus <name> --case <caseKey> [--json]
281
289
  remits-cli corpus retire --corpus <name> --case <caseKey> [--case ...] [--restore] [--json] # drop a case from future runs; past measurements stay readable
282
290
  ```
283
291
 
292
+ `--candidate <hash>` names the staged content you mean to test (the lane content hash `components status`
293
+ reports); if another process restaged or the lane expired, the run is refused with `stale_revision` instead of
294
+ testing different source. It binds the start of the run, not every later lookup.
295
+
284
296
  `test run` starts a server-side task immediately. By default the CLI process polls until the task is
285
297
  terminal; `--watch false` disables websocket progress streaming but still waits. Pass `--wait false` to
286
298
  return after launch with the task id. A single run with `--names "a|b|c"` selects cases into one suite task,
@@ -589,6 +601,10 @@ For tests specifically:
589
601
  - `--names` is `|`-delimited and may be repeated. A selector containing commas matches a case with that
590
602
  exact name first, else its comma-separated parts; an unmatched selector fails the run instead of
591
603
  reporting zero cases as success.
604
+ - `--failed` re-runs only the cases that did not pass in YOUR last finished run of the suite on this git
605
+ branch and staging workspace (a `--parallel` run is read as its whole group; the launch lane is preferred,
606
+ else the lane the suite recorded in). The launch prints the run it read and the cases. "Nothing failed"
607
+ starts nothing and exits 0; no earlier run is refused. Not combinable with `--names`.
592
608
  - `--as-account <ID>` runs AS a descendant subscriber so its edge selects the component branch
593
609
  (*"what does customer X get?"*); `--variant-branch <name>` probes a branch from any checkout
594
610
  (*"what does branch Y look like?"*), and `--variant-branch none` forces production/subscription semantics.
@@ -421,9 +421,17 @@ This applies to:
421
421
  ```bash
422
422
  remits-cli test run --test <TEST_ID_OR_NAME>
423
423
  remits-cli test run --test "Invoice Tests" --names "specific test case"
424
+ remits-cli test run --test "Invoice Tests" --failed
424
425
  remits-cli test status --task-id <TASK_ID>
425
426
  ```
426
427
 
428
+ **Iterate narrowly, and run the whole suite once before landing.** Waiting on test runs is most of
429
+ an hour-long build loop, not staging. A suite whose cases run one after another re-pays every case's
430
+ fixture work on every rerun. Run the suite once to see what fails. After each edit, run `--failed` (the
431
+ cases that did not pass in your last finished run of this suite, on this git branch and staging
432
+ workspace) or `--names` for the cases your change touches. Then run the full suite once before landing.
433
+ A full rerun after every edit, with every case passing, is the most common way an agent spends an hour.
434
+
427
435
  Tests run on the platform against your staged snapshot. They stream results in real-time. **Read the
428
436
  `World:` block printed before the run** — host, execution account, data lane, component world, staging lane
429
437
  id + content hash, platform sync vs local HEAD. If it is not the world you meant, stop: the result will be
@@ -347,19 +347,26 @@ discouraged.
347
347
  - **A refusal is an ANSWER.** The message names the rule and says what to do instead. Act on that
348
348
  sentence. Do not retry the identical call, and do not go looking for another door — the rules govern the
349
349
  DSL, the MCP tools and this CLI identically.
350
- - **`[require]` rules mean a human.** You cannot acknowledge one: the platform decides "human" from the
351
- absence of an agent run identity, which your commands always carry. Record what you found with
352
- `ticket progress` and hand off.
350
+ - **`[require]` rules mean a human.** You cannot acknowledge one. A supervised worker authenticates with a
351
+ credential bound to its own attempt, so the platform knows it is an agent from the credential itself —
352
+ omitting identity headers does not change that. Record what you found with `ticket progress` and hand off.
353
353
  - **`[warn]` rules let the call through and write a `policy` worklog entry.** Mention it in your handoff.
354
354
  - **Rules can name your role** (`"when":{"role":"triage"}`, or `"role":{"not":["release_manager"]}` for
355
355
  "only release managers"). Your role is the one your session registered with; you cannot change it from a
356
356
  worker, and passing one on a call does nothing. A refusal naming your role means hand off to the role that
357
357
  may.
358
- - **If you see "POLICY NOT ENFORCED"**, the account's rules could not be parsed. Do not read the absence
359
- of a refusal as permission — say so on the ticket. It is a defect in the account's `OPERATIONS` prompt.
360
-
361
- The same rules govern `components stage` and `components commit`. A `no-commit` rule is why
362
- `remits-cli components commit` refuses rather than asks.
358
+ - **If you see a policy problem banner**, part of the account's rules could not be parsed. The rules listed
359
+ under it are still enforced (a broken replacement keeps its last valid version); do not read the absence
360
+ of a refusal as permission for the rest — say so on the ticket. **`policy_unavailable`** means a published
361
+ policy could not be read at all: landing, committing, completing and sending are refused until it can be.
362
+ Retry later; do not look for another door.
363
+ - **Check before you act**: `remits-cli policy explain --point commit --as agent` says what the rules would
364
+ decide and which rules apply, without applying anything.
365
+
366
+ The same rules govern `components stage` and `components commit`. Whether you may land at all is the
367
+ account's **landing profile**, stated in your brief: by default a person lands (leave changes in the working
368
+ tree and describe them); an account that sets `supportPolicy.landing: agent` lets you land with
369
+ `remits-cli components commit`, which re-checks the rules when it runs. A `no-commit` rule always wins.
363
370
 
364
371
  ### Roles and who may launch them
365
372
 
@@ -433,6 +440,7 @@ remits-cli ticket accept --ticket 22454 # claim it before changing anyt
433
440
  remits-cli ticket progress --ticket 22454 --summary "Traced it to the posting Action" --category investigation
434
441
  remits-cli ticket status --ticket 22454 --status in_progress
435
442
  remits-cli ticket complete --ticket 22454 --resolution "What you found and did"
443
+ remits-cli ticket cancel --ticket 22454 --reason "Customer withdrew request" # outcome no longer wanted; stops running worker
436
444
  remits-cli ticket ask --ticket 22454 --question "Re-issue or skip?" --context "412 affected"
437
445
  remits-cli ticket answer --ticket 22454 --message "Skip them." # alias: reply
438
446
  remits-cli ticket message --ticket 22454 --message "We reproduced it; a fix is staged."
@@ -590,7 +598,12 @@ remits-cli ticket handoff --summary "fix staged in ticket-19463; needs independe
590
598
  ```
591
599
 
592
600
  The handoff is queued even if nobody is online, `--exclude` keeps you off it (independent review needs
593
- that), and your own exit will not undo it. A reviewer decides with `ticket decide --request R3 --decision
601
+ that), and your own exit will not undo it. It is a checkpoint: `--findings`, `--next-step`,
602
+ `--artifact-refs`, `--evidence-refs`, `--waiting-on` and `--needs` land in the next worker's brief, so give
603
+ durable references (artifact ids, receipt ids), never local paths. Add `--expected-revision N` (the revision
604
+ you read) to `handoff`, `decide` or `complete` and a ticket that moved on is refused with `stale_revision`
605
+ instead of being overwritten. Your evidence trail is published to the ticket as receipts automatically;
606
+ `remits-cli evidence flush` sends anything still queued. A reviewer decides with `ticket decide --request R3 --decision
594
607
  approve|changes|reject --revision <rev>`; "changes" sends the work back with the reviewer's note.
595
608
 
596
609
  An approval or review can need several people: `request-approval ... --min-approvals 2` stays open until two
@@ -144,7 +144,8 @@ account).
144
144
  **Recurring schedules:** `action_schedules` lists the target account's Action schedules;
145
145
  `action_schedule_update` upserts by `targetAccountId` + `actionId` (or an account-scoped `scheduleId`);
146
146
  `action_schedule_delete` removes that schedule and its cloud job. Updates accept `cron`, `timeZone`,
147
- `enabled` and `dryRun`. `enabled:false` pauses; `enabled:true` resumes while preserving omitted settings.
147
+ `enabled` and `dryRun`. Each run executes as the schedule's owner (`ownerUserId` in reads), who defaults to
148
+ whoever first saves it and is rechecked at every run. `enabled:false` pauses; `enabled:true` resumes while preserving omitted settings.
148
149
  `regression_schedule` reads the per-account regression settings and latest report;
149
150
  `regression_schedule_update` accepts the same scheduling fields plus `resultAction`, `suiteConcurrency`
150
151
  (1–16) and explicit `ownerUserId`. Existing owners are preserved. Only admins may assign another owner;