@remits/remits-cli 0.1.168 → 0.1.170

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Local CLI for rapid Remits component testing without push/sync cycles.
4
4
 
5
+ Builder onboarding: `remits-cli builder setup --account-id <owner> --directory <new-checkout>` uses your
6
+ Remits sign-in to clone the account repository with a short-lived Git credential, select a workspace,
7
+ sync guides and install the Remits Builder Claude Code plugin. No GitHub user account is required.
8
+
9
+ Review staged changes: `remits-cli components propose --title "Describe the change"` creates or updates a
10
+ GitHub PR from your current staging lane. It applies account landing policy and leaves merging and
11
+ synchronization to the reviewer. Use `components propose --help` for scope and verification options.
12
+
5
13
  ## Install
6
14
 
7
15
  ```bash
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
+ - L4849 Auth And Component Staging
13
+ - L5485 Component Summaries, Status, And Sync Gates
14
+ - L7826 Branches, Promotion, Commit, And Test Runs
15
+ - L10139 Tokens, Tools, Verification, And Config
16
+ - L11656 Service Dashboard And WebSocket Listener
17
+ - L14166 Agent And Ticket Workflows
18
+ - L18936 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
@@ -4661,6 +4795,56 @@ async function installSkillsCommand(flags) {
4661
4795
  }
4662
4796
  }
4663
4797
 
4798
+ async function builderCommand(flags, subcommand) {
4799
+ const kit = require('./lib/builder-kit');
4800
+ if (flagEnabled(flags.help) || !subcommand) {
4801
+ console.log('remits-cli builder setup --account-id <owner> --directory <new-checkout> [--base-url <host>]');
4802
+ return;
4803
+ }
4804
+ if (!['setup', 'credential'].includes(subcommand)) throw new Error('Expected builder setup.');
4805
+ // Git appends get/store/erase. Never store a token and never emit ordinary CLI output on this door.
4806
+ const operation = flags._ && flags._[2];
4807
+ if (subcommand === 'credential' && operation !== 'get') return;
4808
+ const context = resolveSessionContext(process.cwd(), flags);
4809
+ const { session, accountId } = context;
4810
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
4811
+ const api = buildAxios(baseUrl, session.token);
4812
+ const fields = subcommand === 'credential' ? kit.credentialRequest(fs.readFileSync(0, 'utf8')) : null;
4813
+ if (fields && (fields.protocol !== 'https' || fields.host !== 'github.com' || !fields.path)) return;
4814
+ // Deliberately bypass request evidence logging: this response is a credential, not tool output.
4815
+ let result;
4816
+ try {
4817
+ result = (await api.post('/cli/builderCredential', { token: session.token, accountId })).data;
4818
+ } catch (_) {
4819
+ throw new Error('Builder repository access was refused. Check your Remits sign-in and owner membership.');
4820
+ }
4821
+ if (!result.success || !result.token) throw new Error('No Builder Kit credential was issued.');
4822
+ if (subcommand === 'credential') {
4823
+ if (!kit.matchesRepository(fields, result.repository)) return;
4824
+ process.stdout.write('username=x-access-token\npassword=' + result.token + '\n\n');
4825
+ return;
4826
+ }
4827
+ const directory = path.resolve(flags.directory || result.repository.split('/')[1]);
4828
+ const helper = kit.helperCommand(process.execPath, __filename, accountId, baseUrl);
4829
+ kit.cloneRepository({ cloneUrl: result.cloneUrl, directory, helper });
4830
+ process.chdir(directory);
4831
+ ensureLocalState(directory);
4832
+ await workspaceCommand({ _: ['workspace', 'use'], auto: true });
4833
+ await ensureGuidesSynced(directory, { ...flags, 'account-id': accountId });
4834
+ const plugin = path.join(directory, '.claude', 'skills', 'remits-builder');
4835
+ const refs = path.join(plugin, 'references');
4836
+ ensureDir(path.join(plugin, '.claude-plugin'));
4837
+ atomicWriteFile(path.join(plugin, '.claude-plugin', 'plugin.json'), JSON.stringify({
4838
+ name: 'remits-builder', version: '1.0.0', author: { name: 'Remits' }, license: 'MIT', skills: '.',
4839
+ description: 'Build and verify Remits components in your own workspace.'
4840
+ }, null, 2) + '\n');
4841
+ atomicWriteFile(path.join(plugin, 'SKILL.md'), buildSkillSourceBody(refs));
4842
+ writeSkillReferences(refs);
4843
+ console.log('Builder Kit ready: ' + directory);
4844
+ // Delivered references contain local paths; keep this installation local to this checkout.
4845
+ fs.appendFileSync(path.join(directory, '.git', 'info', 'exclude'), '\n/.claude/skills/remits-builder/\n');
4846
+ }
4847
+
4664
4848
  /*
4665
4849
  ## Auth And Component Staging
4666
4850
  */
@@ -6072,6 +6256,23 @@ async function statusComponentsCommand(flags) {
6072
6256
  return response;
6073
6257
  }
6074
6258
 
6259
+ async function proposeComponentsCommand(flags) {
6260
+ const cwd = process.cwd();
6261
+ const context = resolveSessionContext(cwd, flags);
6262
+ requireAccountRepoContext(cwd, flags, 'components propose', context);
6263
+ const { session, accountId } = context;
6264
+ const api = buildAxios(flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL, session.token);
6265
+ const response = await loggedPost(api, cwd, '/cli/components', {
6266
+ token: session.token, accountId, mode: 'propose',
6267
+ branchName: flags.branch || currentBranch(cwd), workspace: resolveWorkspace(cwd, flags),
6268
+ title: flags.title, acknowledge: flags.acknowledge,
6269
+ verificationEnvelopeId: verificationEnvelopeIdForCommand(cwd, flags, context),
6270
+ dataMode: resolveDataMode(flags, session)
6271
+ }).then(r => r.data);
6272
+ if (!response.success) throw new Error(response.message || 'Proposal failed');
6273
+ console.log(flagEnabled(flags.json) ? JSON.stringify(response, null, 2) : response.pullRequest.url);
6274
+ }
6275
+
6075
6276
  async function lanesComponentsCommand(flags) {
6076
6277
  const cwd = process.cwd();
6077
6278
  ensureLocalState(cwd);
@@ -9131,6 +9332,27 @@ function testRunSummaryLines(status = {}) {
9131
9332
  return lines;
9132
9333
  }
9133
9334
 
9335
+ // A full-suite run that took minutes is the most expensive thing an agent's build loop does, and the run itself
9336
+ // is the one place to say so. Production 2026-11: agents re-ran whole multi-minute suites after every edit,
9337
+ // every case passing. Printed only for an unselected run long enough (90 s of case time) to matter.
9338
+ function testRunPacingLines(status = {}, options = {}) {
9339
+ const minMs = 90 * 1000;
9340
+ const result = status.result || {};
9341
+ const tests = Array.isArray(result.tests) ? result.tests.filter((t) => t && t.name) : [];
9342
+ if (options.selected || tests.length < 4) return [];
9343
+ const durationOf = (t) => Number(t.duration) || 0;
9344
+ const totalMs = tests.reduce((sum, t) => sum + durationOf(t), 0);
9345
+ if (totalMs < minMs) return [];
9346
+ const slowest = tests.slice().sort((a, b) => durationOf(b) - durationOf(a)).slice(0, 3)
9347
+ .filter((t) => durationOf(t) > 0);
9348
+ const lines = ['Pacing: ' + tests.length + ' cases, ' + formatDurationMs(totalMs) + ' of case time' +
9349
+ (slowest.length ? '; slowest ' + slowest.map((t) => JSON.stringify(String(t.name)) + ' ' + formatDurationMs(durationOf(t))).join(', ') : '') + '.'];
9350
+ const ref = options.testRef !== undefined ? JSON.stringify(String(options.testRef)) : '<suite>';
9351
+ lines.push(' While iterating, re-run only what you are fixing: remits-cli test run --test ' + ref + ' --failed (or --names "a|b").');
9352
+ lines.push(' Run the whole suite once before landing.');
9353
+ return lines;
9354
+ }
9355
+
9134
9356
  // Per-case comparison of two runs of the same suite: which cases changed outcome, and how cost and duration
9135
9357
  // moved. The answer to "did this revision do better than the last one" without reconstructing either run.
9136
9358
  function compareTestRunResults(baseResult = {}, headResult = {}) {
@@ -9349,6 +9571,13 @@ function testRunLaunchLines(start = {}, runOptions = {}) {
9349
9571
  if (runOptions.label) lines.push('Label: ' + runOptions.label);
9350
9572
  if (runOptions.params) lines.push('Params: ' + Object.keys(runOptions.params).map((k) => k + '=' + JSON.stringify(runOptions.params[k])).join(' '));
9351
9573
  if (runOptions.repeat) lines.push('Repeat: every selected case runs ' + runOptions.repeat + ' times');
9574
+ if (start.rerunOf && Array.isArray(start.rerunOf.failedCases)) {
9575
+ const r = start.rerunOf;
9576
+ lines.push('Re-running ' + r.failedCases.length + ' case(s) that did not pass in run ' + r.taskId +
9577
+ (r.dataMode ? ' (' + r.dataMode + ' lane)' : '') + ':');
9578
+ r.failedCases.slice(0, 20).forEach((name) => lines.push(' - ' + name));
9579
+ if (r.failedCases.length > 20) lines.push(' ... and ' + (r.failedCases.length - 20) + ' more');
9580
+ }
9352
9581
  if (start.runGroupId) {
9353
9582
  lines.push('Parallel: ' + (start.shardTaskIds || []).length + ' shards (' + (start.shardTaskIds || []).join(', ') + '), read as ONE run by ' + start.runGroupId);
9354
9583
  }
@@ -9383,6 +9612,10 @@ async function testCommand(flags) {
9383
9612
  ? []
9384
9613
  : (Array.isArray(flags.names) ? flags.names : [flags.names]);
9385
9614
  const names = parseTestCaseSelectors(rawNames);
9615
+ const rerunFailed = flagEnabled(flags.failed);
9616
+ if (rerunFailed && names.length) {
9617
+ throw new Error('--failed picks the cases itself; drop --names (or drop --failed).');
9618
+ }
9386
9619
  // --label / --param / --repeat / --parallel: what this run varies, and how it is executed.
9387
9620
  const runOptions = testRunOptionsFromFlags(flags);
9388
9621
  const runGroupId = runOptions.parallel > 1 ? 'grp-' + crypto.randomUUID() : undefined;
@@ -9432,6 +9665,11 @@ async function testCommand(flags) {
9432
9665
  dataMode,
9433
9666
  dataModeSource,
9434
9667
  caseConcurrency: flags.parallel !== undefined && runOptions.parallel === 1 ? 1 : undefined,
9668
+ // --candidate <laneContentHash>: refuse (stale_revision) if the lane no longer holds that staged content.
9669
+ expectedLaneContentHash: flags.candidate || undefined,
9670
+ // --failed: the platform re-runs only the cases that did not pass in your last finished run of this suite
9671
+ // on this git branch and staging workspace (and says which run it read).
9672
+ rerunFailed: rerunFailed || undefined,
9435
9673
  testId: /^\d+$/.test(String(testRef)) ? Number(testRef) : undefined,
9436
9674
  testName: /^\d+$/.test(String(testRef)) ? undefined : String(testRef),
9437
9675
  tests: names
@@ -9450,6 +9688,7 @@ async function testCommand(flags) {
9450
9688
  break;
9451
9689
  } catch (err) {
9452
9690
  const data = err && err.response && err.response.data;
9691
+ if (data && data.code === 'no_recorded_run') throw new Error(data.message);
9453
9692
  if (shardStarts.length && shardStartBusy(err) && Date.now() < shardWaitDeadline) {
9454
9693
  console.error('Shard ' + (shardStarts.length + 1) + ' of ' + runOptions.parallel + ' is waiting for a free suite slot ' +
9455
9694
  '(the platform runs a limited number of suites at once; the shards already running free them).');
@@ -9462,8 +9701,18 @@ async function testCommand(flags) {
9462
9701
  throw err;
9463
9702
  }
9464
9703
  }
9704
+ if (!shardStart.success && shardStart.code === 'nothing_failed' && !shardStarts.length) {
9705
+ // Not an error: the thing --failed exists for is already true. Exit 0 and say what to run next.
9706
+ if (jsonOutput) {
9707
+ console.log(JSON.stringify(shardStart, null, 2));
9708
+ } else {
9709
+ console.log(shardStart.message);
9710
+ console.log('Before landing, run the whole suite once: remits-cli test run --test ' + JSON.stringify(String(testRef)));
9711
+ }
9712
+ return;
9713
+ }
9465
9714
  if (!shardStart.success) {
9466
- throw new Error('Failed to start test run' + (shardStarts.length ? ' (shard ' + (shardStarts.length + 1) + ' of ' + runOptions.parallel +
9715
+ throw new Error('Failed to start test run' + (shardStart.message ? ': ' + shardStart.message : '') + (shardStarts.length ? ' (shard ' + (shardStarts.length + 1) + ' of ' + runOptions.parallel +
9467
9716
  '; the shards already started keep running: ' + shardStarts.map((st) => st.taskId).join(', ') + ')' : ''));
9468
9717
  }
9469
9718
  shardStarts.push(shardStart);
@@ -9564,6 +9813,7 @@ async function testCommand(flags) {
9564
9813
  .forEach((line) => console.log(line));
9565
9814
  testRunSummaryLines(status).forEach((line) => console.log(line));
9566
9815
  testFailureGroupLines(status, { testRef }).forEach((line) => console.log(line));
9816
+ testRunPacingLines(status, { testRef, selected: names.length > 0 || rerunFailed }).forEach((line) => console.log(line));
9567
9817
  printTestRunPivots(status);
9568
9818
  // The whole run object used to be printed here as pretty JSON. One 92-case suite measured 220 KB,
9569
9819
  // most of it identifiers repeated once per case, spent on the turn with the least room left. It is
@@ -13975,7 +14225,7 @@ const AGENT_WORKER_MAX_LOG_BYTES = 8 * 1024 * 1024;
13975
14225
  *
13976
14226
  * `mode` is the autonomy bound, not a preference. `investigate` maps to each CLI's genuine
13977
14227
  * 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).
14228
+ * lets it change files; whether it may land is the account's landing profile, stated in the brief (nothing here runs git).
13979
14229
  */
13980
14230
  const AGENT_LAUNCHERS = {
13981
14231
  codex: {
@@ -15354,6 +15604,22 @@ async function releaseTicketClaim(context, agentId, ticket, options = {}) {
15354
15604
  }
15355
15605
  }
15356
15606
 
15607
+ /**
15608
+ * Per-attempt liveness, in memory in the supervisor: when each child last wrote output and its last
15609
+ * summarized progress line. Reported on the supervisor's poll as `attempts`, so the platform can tell a
15610
+ * connected supervisor from a child that is actually producing work. Never persisted; never a heuristic.
15611
+ */
15612
+ const WORKER_LIVENESS = new Map();
15613
+
15614
+ function noteWorkerLiveness(ticketId, patch) {
15615
+ const key = String(ticketId);
15616
+ WORKER_LIVENESS.set(key, { ...(WORKER_LIVENESS.get(key) || { ticketId: key }), ...patch });
15617
+ }
15618
+
15619
+ function workerLivenessFor(ticketIds) {
15620
+ return (ticketIds || []).map((id) => WORKER_LIVENESS.get(String(id))).filter(Boolean);
15621
+ }
15622
+
15357
15623
  async function postAgentStatus(context, agentId, update) {
15358
15624
  try {
15359
15625
  await buildAxios(context.baseUrl, context.session.token, 10000)
@@ -15370,6 +15636,39 @@ async function postAgentStatus(context, agentId, update) {
15370
15636
  * the same thing. Only the local supervisor can add machine-local repository hints; the worker owns the
15371
15637
  * final grounding step, including creating or choosing a git worktree before it edits.
15372
15638
  */
15639
+ /** Mint a credential bound to one worker attempt. Null (legacy identity) when the server cannot. */
15640
+ async function mintWorkerCredential(context, binding) {
15641
+ try {
15642
+ const response = await buildAxios(context.baseUrl, context.session.token, 15000)
15643
+ .post('/cli/agentCredential', {
15644
+ mode: 'mint', agentId: binding.agentId, ticketId: binding.ticketId, runId: binding.runId,
15645
+ capabilities: binding.capabilities
15646
+ })
15647
+ .then((r) => r.data);
15648
+ if (response && response.success && response.credential) {
15649
+ appendGlobalActivityLog('agent', 'worker.credential_minted', {
15650
+ agentId: binding.agentId, ticketId: binding.ticketId, expiresAt: response.expiresAt
15651
+ });
15652
+ return { credential: response.credential, expiresAt: response.expiresAt };
15653
+ }
15654
+ } catch (err) {
15655
+ appendGlobalActivityLog('agent', 'worker.credential_unavailable', {
15656
+ agentId: binding.agentId, ticketId: binding.ticketId, error: describeError(err)
15657
+ }, 'warn');
15658
+ }
15659
+ return null;
15660
+ }
15661
+
15662
+ async function revokeWorkerCredential(context, workerCredential) {
15663
+ if (!workerCredential || !workerCredential.credential) return;
15664
+ try {
15665
+ await buildAxios(context.baseUrl, context.session.token, 15000)
15666
+ .post('/cli/agentCredential', { mode: 'revoke', credential: workerCredential.credential });
15667
+ } catch (_) {
15668
+ // It expires on its own; a failed revoke only shortens nothing.
15669
+ }
15670
+ }
15671
+
15373
15672
  async function spawnTicketWorker(context, ctx, ticket) {
15374
15673
  const { spawn } = require('child_process');
15375
15674
  const agentId = ctx.agentId;
@@ -15495,6 +15794,15 @@ async function spawnTicketWorker(context, ctx, ticket) {
15495
15794
  });
15496
15795
 
15497
15796
  const attempt = recordWorkerAttempt(agentId, ticketId);
15797
+ // A credential bound to this attempt (agent + ticket + run). Best effort: an older server without the
15798
+ // endpoint leaves the worker on the legacy header-declared identity, exactly as before.
15799
+ // A session the OPERATOR launched read-only (--mode investigate) gets a credential WITHOUT stage/land, so
15800
+ // the platform refuses source changes from it rather than relying on the brief alone. A worker that is
15801
+ // investigating only because another run holds the lease keeps full reach: it may take the lease later.
15802
+ const workerCredential = await mintWorkerCredential(context, {
15803
+ agentId, ticketId, runId,
15804
+ capabilities: ctx.mode === 'investigate' ? ['read', 'ticket', 'test', 'tool', 'token'] : undefined
15805
+ });
15498
15806
  const child = spawn(launcher.binary, built.args, {
15499
15807
  cwd: workingDirectory || ctx.cwd,
15500
15808
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -15505,6 +15813,7 @@ async function spawnTicketWorker(context, ctx, ticket) {
15505
15813
  REMITS_AGENT_ID: agentId,
15506
15814
  REMITS_SUPPORT_TICKET_ID: ticketId,
15507
15815
  REMITS_WORKER_RUN_ID: runId,
15816
+ ...(workerCredential ? { REMITS_AGENT_TOKEN: workerCredential.credential } : {}),
15508
15817
  // Give the worker its OWN staging lane.
15509
15818
  //
15510
15819
  // The edit LEASE already serializes who may edit a repo, and this does not replace it — a lease
@@ -15566,8 +15875,10 @@ async function spawnTicketWorker(context, ctx, ticket) {
15566
15875
  let lastStatusAtMs = 0;
15567
15876
  let buffered = '';
15568
15877
 
15878
+ noteWorkerLiveness(ticketId, { runId, pid: child.pid, startedAtMs: Date.now() });
15569
15879
  const consume = (chunk) => {
15570
15880
  const text = chunk.toString();
15881
+ noteWorkerLiveness(ticketId, { lastOutputAtMs: Date.now() });
15571
15882
  if (logBytes < AGENT_WORKER_MAX_LOG_BYTES) {
15572
15883
  logBytes += text.length;
15573
15884
  logStream.write(text);
@@ -15583,6 +15894,7 @@ async function spawnTicketWorker(context, ctx, ticket) {
15583
15894
  if (nowMs - lastStatusAtMs < AGENT_WORKER_STATUS_THROTTLE_MS) continue;
15584
15895
  lastStatusAtMs = nowMs;
15585
15896
  recordWorker(agentId, ticketId, { activity: summary, activityAt: new Date().toISOString() });
15897
+ noteWorkerLiveness(ticketId, { lastProgress: String(summary).slice(0, 160), lastProgressAtMs: nowMs });
15586
15898
  postAgentStatus(context, agentId, {
15587
15899
  state: 'working', ticketId, activity: summary, step: 'worker:' + agentType
15588
15900
  });
@@ -15597,12 +15909,23 @@ async function spawnTicketWorker(context, ctx, ticket) {
15597
15909
 
15598
15910
  const done = new Promise((resolve) => {
15599
15911
  child.on('error', (err) => {
15912
+ WORKER_LIVENESS.delete(String(ticketId));
15600
15913
  appendGlobalActivityLog('agent', 'worker.spawn_failed',
15601
15914
  { agentId, ticketId, agentType, error: describeError(err) }, 'error');
15602
15915
  resolve({ ticketId, ok: false, reason: 'spawn_failed', message: describeError(err) });
15603
15916
  });
15604
15917
  child.on('close', async (code, signal) => {
15605
15918
  try { logStream.end(); } catch (_) { /* nothing to do */ }
15919
+ // The attempt is over: its credential must not outlive it (a late or orphaned child cannot keep acting).
15920
+ WORKER_LIVENESS.delete(String(ticketId));
15921
+ // The worker may have exited with receipts still queued in its checkout: a worker exit must not erase
15922
+ // unsent evidence. Drained with the SUPERVISOR's session (the worker credential is about to be revoked).
15923
+ if (workingDirectory) {
15924
+ try {
15925
+ await drainEvidenceOutbox(workingDirectory, { ...context, dataMode: context.dataMode || ctx.dataMode }, agentId);
15926
+ } catch (_) { /* stays queued for the next drain */ }
15927
+ }
15928
+ await revokeWorkerCredential(context, workerCredential);
15606
15929
  const ok = code === 0 && !signal;
15607
15930
  appendGlobalActivityLog('agent', 'worker.finished',
15608
15931
  { agentId, ticketId, agentType, code, signal, attempt, logFile }, ok ? 'info' : 'error');
@@ -15758,6 +16081,7 @@ async function agentHeartbeatDaemonCommand(flags) {
15758
16081
  claim: inFlight.size < ctx.maxConcurrent,
15759
16082
  runId: ctx.runId,
15760
16083
  activeTickets,
16084
+ attempts: workerLivenessFor(activeTickets),
15761
16085
  maxConcurrent: ctx.maxConcurrent,
15762
16086
  // Declared so the phase the platform derives already accounts for an operator who asked for a
15763
16087
  // read-only agent. Without it the server would hand out an edit lease this session will never
@@ -16068,6 +16392,8 @@ async function ticketCommand(flags, subcommand) {
16068
16392
 
16069
16393
  const local = ownLocalAgent(flags);
16070
16394
  const context = agentSessionFor(flags, local);
16395
+ // Publish any passive receipts this checkout queued, before the verb that may hand the work to someone else.
16396
+ try { await drainEvidenceOutbox(process.cwd(), context); } catch (_) { /* never blocks the verb */ }
16071
16397
 
16072
16398
  // Routing lives on its own endpoint (the operator surface the dashboard and any Embeddable share),
16073
16399
  // so it is dispatched here rather than folded into the lifecycle switch.
@@ -16111,6 +16437,13 @@ async function ticketCommand(flags, subcommand) {
16111
16437
  route: flags.route === 'false' ? false : undefined,
16112
16438
  details: flags.details,
16113
16439
  nextStep: flags['next-step'],
16440
+ // The structured handoff checkpoint (durable references, never local paths) and the revision a
16441
+ // decision was made from: a stale one is refused with code stale_revision instead of being rebased.
16442
+ artifactRefs: csvFlag(flags['artifact-refs'] || flags.artifactRefs),
16443
+ evidenceRefs: csvFlag(flags['evidence-refs'] || flags.evidenceRefs),
16444
+ waitingOn: csvFlag(flags['waiting-on'] || flags.waitingOn),
16445
+ requiredCapabilities: csvFlag(flags.needs || flags['required-capabilities']),
16446
+ expectedRevision: flags['expected-revision'] !== undefined ? Number(flags['expected-revision']) : undefined,
16114
16447
  category: flags.category,
16115
16448
  // Clearing a planning slot has to be expressible, and `||` could not express it: an empty value is
16116
16449
  // falsy, so it collapsed to undefined, JSON.stringify dropped the key, and the server was never told.
@@ -16904,9 +17237,16 @@ function printTicketHelp() {
16904
17237
  console.log(' cancel-request --request R2 [--reason "..."]');
16905
17238
  console.log(' handoff --summary "what the next worker should do" [--workstream review] [--exclude]');
16906
17239
  console.log(' [--review-action promote --revision R] [--next-step "..."] [--findings "a|b"]');
17240
+ console.log(' [--artifact-refs a,b] [--evidence-refs e1,e2] [--waiting-on X] [--needs capability]');
16907
17241
  console.log(' Hand the next step to another session (queued if nobody is online). --exclude keeps');
16908
17242
  console.log(' YOU off it — use it for independent review. Your exit will not erase this handoff.');
17243
+ console.log(' The checkpoint (findings, next step, refs, waiting-on, needs) is in the next worker\'s brief.');
17244
+ console.log(' --expected-revision N (handoff, decide, complete): refuse with stale_revision if the ticket moved');
17245
+ console.log(' past revision N since you read it, instead of merging your decision over newer changes.');
16909
17246
  console.log(' continue [--reason requested] Queue the ticket for the automation to work again.');
17247
+ console.log(' cancel --reason "..." [--expected-revision N] The outcome is no longer wanted: closed with disposition');
17248
+ console.log(' cancelled; history kept; open requests cancelled; anything already landed or sent is NOT undone.');
17249
+ console.log(' redeliver-events OPERATOR: re-dispatch undelivered lifecycle events (deduplicated by eventId).');
16910
17250
  console.log(' reset-budget OPERATOR: restore a ticket\'s run and spend budget after looking at it.');
16911
17251
  console.log(' wake --in MINUTES | --at ISO-TIME [--reason "..."] | wake --cancel');
16912
17252
  console.log(' A durable timer: when it is due the ticket is runnable work again (reason `timer`) and');
@@ -17083,6 +17423,113 @@ function usageListFlag(value) {
17083
17423
  return items.length ? items : undefined;
17084
17424
  }
17085
17425
 
17426
+ /** `remits-cli capabilities` — the platform's collaboration catalog (features, verbs, refusal codes). */
17427
+ async function capabilitiesCommand(flags) {
17428
+ const cwd = process.cwd();
17429
+ let baseUrl = flags['base-url'];
17430
+ if (!baseUrl) {
17431
+ try { baseUrl = resolveSessionContext(cwd, flags).session.baseUrl; } catch (_) { /* no session is fine */ }
17432
+ }
17433
+ baseUrl = baseUrl || DEFAULT_BASE_URL;
17434
+ let response;
17435
+ try {
17436
+ response = await axios.get(baseUrl.replace(/\/$/, '') + '/cli/capabilities', { timeout: 15000, headers: cliRequestHeaders(null) })
17437
+ .then((r) => r.data);
17438
+ } catch (err) {
17439
+ if (err && err.response && err.response.status === 404) {
17440
+ response = { success: false, code: 'unsupported',
17441
+ message: 'This platform predates the capability catalog; it supports none of the negotiated collaboration features.' };
17442
+ } else {
17443
+ throw err;
17444
+ }
17445
+ }
17446
+ if (flagEnabled(flags.json)) {
17447
+ console.log(JSON.stringify(response, null, 2));
17448
+ return response;
17449
+ }
17450
+ if (!response || response.success !== true) {
17451
+ console.log(response && response.message ? response.message : 'No capability catalog available.');
17452
+ return response;
17453
+ }
17454
+ console.log('Collaboration capabilities — ' + baseUrl + ' (protocol ' + response.protocolVersion + ')');
17455
+ console.log('');
17456
+ console.log('Features:');
17457
+ Object.entries(response.featureDetails || {}).forEach(([name, detail]) => console.log(' ' + name.padEnd(24) + ' ' + detail));
17458
+ console.log('');
17459
+ console.log('Verbs:');
17460
+ (response.verbs || []).forEach((v) => console.log(' ' + String(v.verb).padEnd(30) + ' ' + (v.sideEffect || '') +
17461
+ (v.refusals && v.refusals.length ? ' [refusals: ' + v.refusals.join(', ') + ']' : '')));
17462
+ console.log('');
17463
+ console.log('Refusal codes:');
17464
+ Object.entries(response.codes || {}).forEach(([code, meaning]) => console.log(' ' + code.padEnd(20) + ' ' + meaning));
17465
+ console.log('');
17466
+ console.log(response.note);
17467
+ return response;
17468
+ }
17469
+
17470
+ /**
17471
+ * `remits-cli policy explain` — what the account's deterministic rules would decide for a proposed action,
17472
+ * and why, WITHOUT applying it. The verb re-evaluates when it actually runs; this is the preview.
17473
+ */
17474
+ async function policyExplainCommand(flags) {
17475
+ const cwd = process.cwd();
17476
+ ensureLocalState(cwd);
17477
+ const sessionContext = resolveSessionContext(cwd, flags);
17478
+ const { session, accountId } = sessionContext;
17479
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
17480
+ const api = buildAxios(baseUrl, session.token, 60000);
17481
+ const components = csvFlag(flags.components || flags.component);
17482
+ const response = await loggedPost(api, cwd, '/cli/policyExplain', {
17483
+ // The checkout's branch and staging workspace: a person explaining their STAGED process draft is answered
17484
+ // from it (the authoring lane shows staged prompts only when the call names its branch).
17485
+ ...runContextPayload(flags),
17486
+ token: session.token,
17487
+ accountId: flags['target-account-id'] ? Number(flags['target-account-id']) : accountId,
17488
+ dataMode: resolveDataMode(flags, session),
17489
+ point: flags.point,
17490
+ workstream: flags.workstream,
17491
+ capabilities: csvFlag(flags.capabilities || flags.capability) || undefined,
17492
+ actorKind: flags.as || flags['actor-kind'],
17493
+ acknowledge: flags.acknowledge,
17494
+ components: components ? components.map((entry) => {
17495
+ const [type, name, category] = String(entry).split(':');
17496
+ return { type, name, category };
17497
+ }) : undefined
17498
+ }).then((r) => r.data).catch((err) => {
17499
+ if (err && err.response && err.response.data) return err.response.data;
17500
+ throw err;
17501
+ });
17502
+ if (flagEnabled(flags.json)) {
17503
+ console.log(JSON.stringify(response, null, 2));
17504
+ return response;
17505
+ }
17506
+ if (!response || response.success !== true) {
17507
+ throw new Error((response && response.message) || 'Policy explanation failed');
17508
+ }
17509
+ const decision = response.decision || {};
17510
+ console.log('Policy at "' + response.point + '" on account ' + response.accountId +
17511
+ (response.workstream ? ' (workstream ' + response.workstream + ')' : '') + ' — ' + response.dataMode + ' lane');
17512
+ console.log(' as: ' + ((response.actor && response.actor.kind) || '?') +
17513
+ ((response.actor && response.actor.evidence && response.actor.evidence.length) ? ' (' + response.actor.evidence.join(', ') + ')' : ''));
17514
+ console.log(' decision: ' + (decision.allowed ? 'ALLOWED' : 'REFUSED (' + (decision.code || 'policy_denied') + ')'));
17515
+ if (decision.message) console.log(' ' + decision.message);
17516
+ (decision.warnings || []).forEach((w) => console.log(' warn ' + w.ruleId + ': ' + (w.message || '')));
17517
+ const relevant = (response.rules || []).filter((r) => r.appliesAtPoint || r.fires);
17518
+ if (relevant.length) {
17519
+ console.log(' rules that apply at this point:');
17520
+ relevant.forEach((r) => console.log(' ' + (r.fires ? '*' : ' ') + ' [' + r.grade + '] ' + r.id +
17521
+ (r.final ? ' (final)' : '') + ' from ' + (r.source || '?') +
17522
+ (r.distance !== undefined ? ' (distance ' + r.distance + ')' : '')));
17523
+ } else {
17524
+ console.log(' no rule applies at this point.');
17525
+ }
17526
+ const eff = response.effectivePolicy || {};
17527
+ if (eff.unavailable) console.log(' POLICY UNAVAILABLE: mandatory decisions are refused until it can be read.');
17528
+ if (eff.error) console.log(' policy problem: ' + eff.error);
17529
+ console.log(' ' + response.note);
17530
+ return response;
17531
+ }
17532
+
17086
17533
  async function usageCommand(flags) {
17087
17534
  const cwd = process.cwd();
17088
17535
  ensureLocalState(cwd);
@@ -17540,9 +17987,29 @@ function listWorkstreamIndexes(cwd) {
17540
17987
  .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
17541
17988
  }
17542
17989
 
17990
+ function evidenceOutboxPending(cwd) {
17991
+ try {
17992
+ const file = evidenceOutboxFile(cwd);
17993
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).length : 0;
17994
+ } catch (_) {
17995
+ return 0;
17996
+ }
17997
+ }
17998
+
17543
17999
  async function evidenceCommand(flags) {
17544
18000
  const cwd = process.cwd();
17545
18001
  ensureLocalState(cwd);
18002
+ if (flags._ && flags._[1] === 'flush') {
18003
+ const context = agentSessionFor(flags, ownLocalAgent(flags));
18004
+ const result = await drainEvidenceOutbox(cwd, context);
18005
+ if (flagEnabled(flags.json)) {
18006
+ console.log(JSON.stringify({ success: true, ...result }, null, 2));
18007
+ } else {
18008
+ console.log('Published ' + result.sent + ' receipt(s)' + (result.duplicate ? ', ' + result.duplicate + ' already recorded' : '') +
18009
+ (result.rejected ? ', ' + result.rejected + ' rejected' : '') + (result.kept ? '; ' + result.kept + ' still queued' : '') + '.');
18010
+ }
18011
+ return result;
18012
+ }
17546
18013
  const limit = Math.max(1, Math.min(parseInt(flags.limit || flags.max || '40', 10) || 40, 500));
17547
18014
  const entries = readEvidenceTrail(cwd, limit);
17548
18015
  if (flagEnabled(flags.json)) {
@@ -17551,6 +18018,10 @@ async function evidenceCommand(flags) {
17551
18018
  }
17552
18019
  const paths = localStatePaths(cwd);
17553
18020
  console.log('Evidence trail for actor ' + paths.actor.id + ' (' + paths.evidenceFile + ')');
18021
+ const pending = evidenceOutboxPending(cwd);
18022
+ if (pending) {
18023
+ console.log(pending + ' receipt(s) queued for the work item and not yet published — remits-cli evidence flush');
18024
+ }
17554
18025
  if (!entries.length) {
17555
18026
  console.log('Nothing recorded yet. Every stage, test, token, tool and sync appends one world-stamped line here,');
17556
18027
  console.log('with no envelope and nothing to start.');
@@ -17652,6 +18123,35 @@ async function workstreamCommand(flags, subcommand) {
17652
18123
  * and the one an operator asks when a queue is not moving. Agents, leases and tickets were each readable
17653
18124
  * on their own and never together, so every caller invented its own join and they disagreed.
17654
18125
  */
18126
+ /**
18127
+ * One line from the platform's CollaborationActivity projection. Null when an older server sent none.
18128
+ * Live work and stored facts are named separately, and unknown coverage is said, never shown as zero.
18129
+ */
18130
+ function describeActivityHeadline(activity) {
18131
+ if (!activity || typeof activity !== 'object') return null;
18132
+ const parts = [];
18133
+ const coverage = activity.coverage || {};
18134
+ if (coverage.registry === 'unavailable') {
18135
+ parts.push('live participants unknown');
18136
+ } else {
18137
+ const running = (activity.attempts && activity.attempts.running) || 0;
18138
+ const online = (activity.participants && activity.participants.online) || 0;
18139
+ parts.push('live: ' + running + ' attempt(s) running, ' + online + ' participant(s) online');
18140
+ }
18141
+ if (coverage.staging === 'unavailable') {
18142
+ parts.push('staging unknown');
18143
+ } else if (activity.staged && activity.staged.laneCount) {
18144
+ const changes = activity.changes || {};
18145
+ parts.push('stored: ' + (changes.componentCount != null ? changes.componentCount + ' last-reported change(s), ' : '') +
18146
+ activity.staged.entryCount + ' staged entr(ies) in ' + activity.staged.laneCount + ' lane(s)' +
18147
+ (changes.asOf ? ' (as of ' + changes.asOf + ')' : ''));
18148
+ }
18149
+ if (Array.isArray(activity.offlineAuthors) && activity.offlineAuthors.length) {
18150
+ parts.push('staged by offline author(s): ' + activity.offlineAuthors.join(', '));
18151
+ }
18152
+ return ' ' + parts.join(' · ');
18153
+ }
18154
+
17655
18155
  async function agentWorkMapCommand(flags) {
17656
18156
  const agentId = resolveAgentId(flags, { optional: true });
17657
18157
  const local = agentId ? (listLocalAgents().find((entry) => entry.agentId === agentId) || {}) : {};
@@ -17678,12 +18178,19 @@ async function agentWorkMapCommand(flags) {
17678
18178
  return;
17679
18179
  }
17680
18180
  console.log('Work map — ' + context.baseUrl + ' · ' + response.dataMode + ' lane');
18181
+ // The platform's activity projection (the same one the account console renders): live work is the
18182
+ // running/online counts; last-reported changes and the staged overlay are stored facts with an as-of.
18183
+ const activityLine = describeActivityHeadline(response && response.activity);
18184
+ if (activityLine) console.log(activityLine);
17681
18185
  console.log('');
17682
18186
  for (const repo of repositories) {
17683
18187
  const lease = repo.editLease;
17684
18188
  console.log(' ' + repo.accountName + ' (' + repo.accountId + ')' +
17685
18189
  (repo.accountType ? ' ' + repo.accountType : ''));
17686
- if (lease) {
18190
+ if (lease && lease.unavailable) {
18191
+ // UNKNOWN is its own state: nobody may edit, and nobody is named as the holder.
18192
+ console.log(' EDIT LEASE unknown (coordination store unavailable) — investigate read-only');
18193
+ } else if (lease) {
17687
18194
  const who = lease.agentLabel || lease.agentId;
17688
18195
  console.log(' EDIT LEASE ' + who +
17689
18196
  (lease.ticketId ? ' ticket ' + lease.ticketId : '') +
@@ -18728,7 +19235,13 @@ function printComponentsHelp(subcommand) {
18728
19235
  console.log('--safe also gates the server sync plan before any trunk rows or ComponentVariant overlays are written.');
18729
19236
  return;
18730
19237
  }
18731
- console.log('Usage: remits-cli components <stage|status|lanes|entries|clear|sync|commit|promotion|branches|branch>');
19238
+ if (subcommand === 'propose') {
19239
+ console.log('Usage: remits-cli components propose [--account-id ID] [--branch BASE] [--workspace NAME] [--title TITLE] [--verify-envelope ID] [--acknowledge TEXT] [--json]');
19240
+ console.log('Creates or updates a GitHub PR from every staged component in your lane. Account landing policy applies.');
19241
+ console.log('Review the PR before merging; this command does not merge or synchronize components.');
19242
+ return;
19243
+ }
19244
+ console.log('Usage: remits-cli components <stage|status|lanes|entries|clear|sync|commit|propose|promotion|branches|branch>');
18732
19245
  console.log('');
18733
19246
  console.log('All staging subcommands accept --workspace NAME to isolate a lane on the same branch,');
18734
19247
  console.log('so several agents can iterate at once. See: remits-cli workspace --help');
@@ -18759,7 +19272,7 @@ function printComponentsHelp(subcommand) {
18759
19272
  }
18760
19273
 
18761
19274
  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]');
19275
+ 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
19276
  console.log(' remits-cli test status --task-id <taskId> [--base-url URL] [--account-id ID] [--branch stagingScope] [--data-mode test|prod] [--json]');
18764
19277
  console.log(' remits-cli test runs [--test <id|name>] [--limit 20] [--compare] [--json] # durable run history');
18765
19278
  console.log(' remits-cli test compare --base <taskId> --head <taskId> [--json] # per-case outcome/cost deltas');
@@ -18768,11 +19281,15 @@ function printTestHelp() {
18768
19281
  console.log('Examples:');
18769
19282
  console.log(' remits-cli test run --test 11');
18770
19283
  console.log(' remits-cli test run --test "Merchant Statements" --names "managed account case"');
19284
+ console.log(' remits-cli test run --test "Merchant Statements" --failed');
18771
19285
  console.log(' remits-cli test status --task-id 7a1b...');
18772
19286
  console.log('');
18773
19287
  console.log('Notes:');
18774
19288
  console.log(' --names is delimited by "|" (comma also works when no "|" is present), and may be repeated.');
18775
19289
  console.log(' A selector that matches no case fails the run rather than reporting 0 passed / 0 failed.');
19290
+ console.log(' --failed re-runs only the cases that did not pass in your last finished run of this suite on');
19291
+ console.log(' this git branch and staging workspace. Iterate with --failed / --names; run the whole');
19292
+ console.log(' suite once before landing.');
18776
19293
  console.log(' --as-account changes the execution account so subscriber branch edges apply.');
18777
19294
  console.log(' --variant-branch <name> probes a committed variant branch; use "none" or "trunk"');
18778
19295
  console.log(' to force production/subscription semantics from a variant checkout.');
@@ -18963,6 +19480,11 @@ async function main() {
18963
19480
  const wantsHelp = args.help === true || subcommand === 'help' || subcommand === '--help' || args._.includes('--help');
18964
19481
  const wantsJson = flagEnabled(args.json);
18965
19482
 
19483
+ if (command === 'builder') {
19484
+ await builderCommand(args, subcommand);
19485
+ return;
19486
+ }
19487
+
18966
19488
  // Inference callbacks must keep one protocol version for the supervisor's lifetime. They do not
18967
19489
  // auto-update, auto-start the support listener, or depend on a component checkout.
18968
19490
  if (command === 'ai') {
@@ -19048,6 +19570,8 @@ async function main() {
19048
19570
  console.log(' remits-cli activity inspect [--account-id ID] [--scope self|children|related] [--workspace NAME] [--workstream ID] [--user-email E] [--summary|--compact] [--json]');
19049
19571
  console.log(' remits-cli workstream status [--workstream ID|--workspace NAME] [--json]');
19050
19572
  console.log(' remits-cli usage --from DATE [--to DATE] [--by hour|day|month] [--group-by account,sku] [--capabilities ai,document_store] [--json]');
19573
+ console.log(' remits-cli capabilities [--json] Collaboration features, verbs and refusal codes this platform supports');
19574
+ console.log(' remits-cli policy explain --point commit [--as agent] What the account rules would decide, and why (nothing applied)');
19051
19575
  console.log(' remits-cli agent release # stop receiving tickets now');
19052
19576
  console.log(' remits-cli start [--foreground true] [--port 8787] # human dashboard in the browser');
19053
19577
  console.log(' remits-cli stop');
@@ -19079,7 +19603,7 @@ async function main() {
19079
19603
  console.log(' remits-cli components branch <name> --subscribe <accountId> [--dry-run] [--confirm-primary-edge]');
19080
19604
  console.log(' remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk');
19081
19605
  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]');
19606
+ 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
19607
  console.log(' remits-cli test runs [--test <id|name>] [--compare] # durable run history; compare latest two');
19084
19608
  console.log(' remits-cli test compare --base <taskId> --head <taskId>');
19085
19609
  console.log(' remits-cli corpus import --manifest corpus-manifest.json # seed a test corpus: cases + artifacts');
@@ -19187,6 +19711,11 @@ async function main() {
19187
19711
  return;
19188
19712
  }
19189
19713
 
19714
+ if (command === 'components' && subcommand === 'propose') {
19715
+ await proposeComponentsCommand(args);
19716
+ return;
19717
+ }
19718
+
19190
19719
  if (command === 'components' && (subcommand === 'lanes' || subcommand === 'lane')) {
19191
19720
  await lanesComponentsCommand(args);
19192
19721
  return;
@@ -19310,6 +19839,33 @@ async function main() {
19310
19839
  throw new Error('Unknown activity subcommand: ' + subcommand);
19311
19840
  }
19312
19841
 
19842
+ if (command === 'capabilities') {
19843
+ if (wantsHelp) {
19844
+ console.log('Usage: remits-cli capabilities [--json] [--base-url URL]');
19845
+ console.log('');
19846
+ console.log('What this platform build supports for collaboration: negotiated features, the stable verbs and');
19847
+ console.log('how each settles, and what every refusal code means. Read-only; needs no special access.');
19848
+ return;
19849
+ }
19850
+ await capabilitiesCommand(args);
19851
+ return;
19852
+ }
19853
+
19854
+ if (command === 'policy') {
19855
+ if (wantsHelp || subcommand !== 'explain') {
19856
+ console.log('Usage: remits-cli policy explain --point <commit|land|stage|complete|deliver|...> [--workstream W]');
19857
+ console.log(' [--capabilities a,b] [--as human|agent] [--components type:name[:category],...]');
19858
+ console.log(' [--target-account-id ID] [--json]');
19859
+ console.log('');
19860
+ console.log("What this account's remits-policy rules would decide for that action, which rules apply, where");
19861
+ console.log('they come from (document, account distance, final) — without applying anything. The real verb');
19862
+ console.log('re-checks the policy in force when it runs.');
19863
+ return;
19864
+ }
19865
+ await policyExplainCommand(args);
19866
+ return;
19867
+ }
19868
+
19313
19869
  if (command === 'usage') {
19314
19870
  if (wantsHelp) {
19315
19871
  console.log('Usage: remits-cli usage --from DATE [--to DATE] [--timezone ZONE] [--by hour|day|month]');
@@ -19329,7 +19885,7 @@ async function main() {
19329
19885
 
19330
19886
  if (command === 'evidence') {
19331
19887
  if (wantsHelp) {
19332
- console.log('Usage: remits-cli evidence [--limit N] [--json]');
19888
+ console.log('Usage: remits-cli evidence [--limit N] [--json] | remits-cli evidence flush');
19333
19889
  console.log('');
19334
19890
  console.log('What you have actually run, and in which world. Every stage, test, token, tool and sync');
19335
19891
  console.log('appends one world-stamped line automatically - there is nothing to start and nothing to');
@@ -19337,6 +19893,9 @@ async function main() {
19337
19893
  console.log('');
19338
19894
  console.log('This is per-actor, so agents sharing a checkout never read each other\'s trail.');
19339
19895
  console.log('Use `remits-cli verify` only when someone needs a checkable VERDICT on top of this.');
19896
+ console.log('');
19897
+ console.log('Inside a work attempt (REMITS_SUPPORT_TICKET_ID), each line is also queued and published to the');
19898
+ console.log('work item as a passive receipt, so a successor elsewhere can read it. `flush` publishes now.');
19340
19899
  return;
19341
19900
  }
19342
19901
  await evidenceCommand(args);
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFileSync } = require('child_process');
6
+
7
+ function credentialRequest(text) {
8
+ const fields = {};
9
+ for (const line of String(text).split(/\r?\n/)) {
10
+ const split = line.indexOf('=');
11
+ if (split > 0) fields[line.slice(0, split)] = line.slice(split + 1);
12
+ }
13
+ return fields;
14
+ }
15
+
16
+ function matchesRepository(fields, repository) {
17
+ return fields.protocol === 'https' && fields.host === 'github.com' &&
18
+ String(fields.path || '').replace(/\.git$/, '') === repository;
19
+ }
20
+
21
+ function helperCommand(node, entry, accountId, baseUrl) {
22
+ const quote = s => "'" + String(s).replace(/'/g, "'\\''") + "'";
23
+ return '!' + [node, entry, 'builder', 'credential', '--account-id', accountId, '--base-url', baseUrl].map(quote).join(' ');
24
+ }
25
+
26
+ function cloneRepository({ cloneUrl, directory, helper }) {
27
+ if (fs.existsSync(directory)) throw new Error('Choose a new directory for Builder Kit setup.');
28
+ const url = new URL(cloneUrl);
29
+ if (url.protocol !== 'https:' || url.hostname !== 'github.com' || url.username || url.password) {
30
+ throw new Error('Invalid Builder Kit repository URL.');
31
+ }
32
+ // Disable inherited credential stores for this operation; only our helper sees the issued token.
33
+ execFileSync('git', ['-c', 'credential.helper=', '-c', 'credential.helper=' + helper,
34
+ '-c', 'credential.useHttpPath=true', 'clone', '--', cloneUrl, directory],
35
+ { stdio: 'inherit', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
36
+ const git = args => execFileSync('git', ['-C', directory, ...args], { stdio: 'pipe' });
37
+ git(['config', '--local', '--add', 'credential.helper', '']);
38
+ git(['config', '--local', '--add', 'credential.helper', helper]);
39
+ git(['config', '--local', 'credential.useHttpPath', 'true']);
40
+ return path.resolve(directory);
41
+ }
42
+
43
+ module.exports = { credentialRequest, matchesRepository, helperCommand, cloneRepository };
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.170",
4
4
  "description": "Local CLI for auth, component sync, and live test execution against Remits",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -12,6 +12,7 @@
12
12
  "files": [
13
13
  "index.js",
14
14
  "lib/sync-operation.js",
15
+ "lib/builder-kit.js",
15
16
  "lib/local-ai.js",
16
17
  "lib/websocket-retry.js",
17
18
  "README.md",
@@ -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
 
@@ -8,6 +8,7 @@
8
8
 
9
9
  ## Table of Contents
10
10
 
11
+ - [Builder Kit and review proposals](#builder-kit-and-review-proposals)
11
12
  - [Getting Started](#getting-started)
12
13
  - [Authentication](#authentication)
13
14
  - [Host vs Data Mode](#host-vs-data-mode)
@@ -21,6 +22,22 @@
21
22
  - [Staging modes: workset vs full snapshot](#staging-modes-workset-vs-full-snapshot)
22
23
  - [Prod banners and retryable failures](#prod-banners-and-retryable-failures)
23
24
 
25
+ ## Builder Kit and review proposals
26
+
27
+ `remits-cli builder setup --account-id <owner> --directory <new-directory>` clones the owning account's
28
+ repository using your Remits builder sign-in. Add `--base-url` to use a local server. It installs a Git
29
+ credential helper, chooses your workspace, syncs guides and installs the Remits Builder Claude Code plugin.
30
+ Git credentials stay in memory, are restricted to that repository and expire within one hour; the next
31
+ issuance checks your current membership. Existing branch protections and account policies still apply.
32
+
33
+ After staging and testing, `remits-cli components propose --title "Describe the change"` creates or updates
34
+ a GitHub PR containing **every staged entry in your current lane**. `--branch` selects the base;
35
+ `--workspace` selects your lane. It preserves component sidecars, records staged hashes and includes a
36
+ verification envelope when available. `--json` returns the PR URL and branch. Landing policy can refuse it.
37
+ The command does not merge, sync, clear staging or commit your local checkout. Review and merge the PR,
38
+ then use the normal sync flow. Start a new workspace after a proposal is closed. If someone edits the PR
39
+ branch directly, reconcile that work before proposing again.
40
+
24
41
  ## Getting Started
25
42
 
26
43
  ### Authentication
@@ -218,13 +235,21 @@ remits-cli agent register [--label NAME] [--data-mode test|prod] [--account-id I
218
235
  remits-cli agent work [--wait SECONDS] [--json]
219
236
  remits-cli agent status [--state idle|working|paused] [--ticket ID] [--activity "..."] [--step "..."]
220
237
  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
238
+ 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
239
+ remits-cli capabilities [--json] # collaboration features this platform supports, verbs + how they settle, refusal codes
240
+ 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
241
+ 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
242
  remits-cli agent inspect [--account-id ID] [--scope self|children] [--user-id ID|--user-email EMAIL] [--limit N] [--json]
223
243
  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
244
  remits-cli workstream status [--workstream ID|--workspace NAME] [--json]
225
245
  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
246
+ 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 "..."]
247
+ remits-cli ticket cancel --ticket ID --reason "..." [--expected-revision N] # close with disposition cancelled; history kept; stops worker
248
+ remits-cli ticket reset-budget --ticket ID # OPERATOR: restore run and spend budget after review
249
+ remits-cli ticket redeliver-events --ticket ID # OPERATOR: re-dispatch undelivered lifecycle events
250
+ 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
251
+ 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]
252
+ remits-cli ticket decide|complete ... [--expected-revision N] # refused with code=stale_revision (nothing written) if the ticket moved past N
228
253
  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
254
  remits-cli ticket ask --ticket ID --question "..." [--context "..."] [--to WHO] # park on a human decision
230
255
  remits-cli ticket answer --ticket ID --message "..." [--route false] # answer it (alias: reply); re-routes by default
@@ -265,7 +290,7 @@ remits-cli components branch <name> --copy-to <newBranch> [--dry-run] [--force]
265
290
  remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>] [--dry-run] [--confirm-primary-edge] # make an account resolve this branch
266
291
  remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk
267
292
  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]
293
+ 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
294
  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
295
  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
296
  remits-cli test compare --base <taskId> --head <taskId> [--json] # per-case improved / regressed / changed, cost and world deltas
@@ -281,6 +306,10 @@ remits-cli corpus consistency --corpus <name> --case <caseKey> [--json]
281
306
  remits-cli corpus retire --corpus <name> --case <caseKey> [--case ...] [--restore] [--json] # drop a case from future runs; past measurements stay readable
282
307
  ```
283
308
 
309
+ `--candidate <hash>` names the staged content you mean to test (the lane content hash `components status`
310
+ reports); if another process restaged or the lane expired, the run is refused with `stale_revision` instead of
311
+ testing different source. It binds the start of the run, not every later lookup.
312
+
284
313
  `test run` starts a server-side task immediately. By default the CLI process polls until the task is
285
314
  terminal; `--watch false` disables websocket progress streaming but still waits. Pass `--wait false` to
286
315
  return after launch with the task id. A single run with `--names "a|b|c"` selects cases into one suite task,
@@ -589,6 +618,10 @@ For tests specifically:
589
618
  - `--names` is `|`-delimited and may be repeated. A selector containing commas matches a case with that
590
619
  exact name first, else its comma-separated parts; an unmatched selector fails the run instead of
591
620
  reporting zero cases as success.
621
+ - `--failed` re-runs only the cases that did not pass in YOUR last finished run of the suite on this git
622
+ branch and staging workspace (a `--parallel` run is read as its whole group; the launch lane is preferred,
623
+ else the lane the suite recorded in). The launch prints the run it read and the cases. "Nothing failed"
624
+ starts nothing and exits 0; no earlier run is refused. Not combinable with `--names`.
592
625
  - `--as-account <ID>` runs AS a descendant subscriber so its edge selects the component branch
593
626
  (*"what does customer X get?"*); `--variant-branch <name>` probes a branch from any checkout
594
627
  (*"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;