amicus 4.9.2 → 4.9.4

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.
Files changed (72) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +324 -0
  3. package/README.md +1 -1
  4. package/bin/amicus.js +6 -0
  5. package/docs/ROADMAP.md +5 -4
  6. package/docs/architecture-map.md +732 -0
  7. package/docs/configuration.md +175 -1
  8. package/docs/council.md +9 -0
  9. package/docs/doc-system.md +12 -9
  10. package/docs/testing.md +2 -1
  11. package/docs/troubleshooting.md +76 -0
  12. package/docs/usage.md +14 -6
  13. package/electron/main.js +25 -2
  14. package/electron/setup-ui-alias-groups.js +161 -0
  15. package/electron/setup-ui-alias-script.js +70 -4
  16. package/electron/setup-ui-aliases.js +25 -21
  17. package/electron/setup-ui.js +11 -1
  18. package/package.json +1 -1
  19. package/schemas/model-catalog.schema.json +2 -1
  20. package/schemas/run.schema.json +13 -0
  21. package/skills/sidecar/SKILL.md +1 -8
  22. package/src/cli-handlers-doctor.js +12 -16
  23. package/src/cli-handlers-fanout.js +10 -1
  24. package/src/cli-handlers-resume-continue.js +25 -0
  25. package/src/cli-handlers.js +17 -1
  26. package/src/cli.js +5 -8
  27. package/src/council/briefings-chair.js +4 -2
  28. package/src/council/run-assemble.js +7 -2
  29. package/src/council/run-retry-notes.js +21 -1
  30. package/src/council/run-stages.js +8 -1
  31. package/src/headless.js +125 -7
  32. package/src/mcp-server.js +26 -0
  33. package/src/mcp-tools.js +4 -4
  34. package/src/opencode-client.js +84 -8
  35. package/src/pack/pack-validate.js +3 -0
  36. package/src/session-manager.js +2 -2
  37. package/src/sidecar/continue.js +6 -1
  38. package/src/sidecar/conversation-mirror.js +35 -11
  39. package/src/sidecar/fanout-leg-fallback.js +1 -0
  40. package/src/sidecar/fanout-leg.js +10 -2
  41. package/src/sidecar/fanout.js +2 -2
  42. package/src/sidecar/interactive.js +31 -4
  43. package/src/sidecar/models-ceiling-line.js +72 -0
  44. package/src/sidecar/models.js +4 -2
  45. package/src/sidecar/reopen-notices.js +97 -0
  46. package/src/sidecar/reopen-spend.js +3 -2
  47. package/src/sidecar/resume.js +15 -2
  48. package/src/sidecar/session-finalize.js +4 -1
  49. package/src/sidecar/session-utils.js +5 -1
  50. package/src/sidecar/start-metadata.js +1 -1
  51. package/src/sidecar/start.js +10 -5
  52. package/src/utils/api-key-validation.js +183 -94
  53. package/src/utils/config.js +65 -2
  54. package/src/utils/curated-models.js +8 -8
  55. package/src/utils/degrade.js +7 -0
  56. package/src/utils/doctor-credit-check.js +61 -0
  57. package/src/utils/doctor-key-auth-check.js +271 -0
  58. package/src/utils/doctor-output-budget-check.js +198 -0
  59. package/src/utils/engine-output-flag.js +105 -0
  60. package/src/utils/engine-variants.js +298 -0
  61. package/src/utils/http-get.js +284 -0
  62. package/src/utils/live-probes.js +53 -0
  63. package/src/utils/model-catalog.js +36 -4
  64. package/src/utils/model-ceilings-modelsdev.js +230 -0
  65. package/src/utils/model-fetcher.js +14 -36
  66. package/src/utils/model-output-limit.js +132 -0
  67. package/src/utils/openrouter-credit.js +104 -0
  68. package/src/utils/output-length.js +90 -0
  69. package/src/utils/result-schema.js +7 -2
  70. package/src/utils/spend-ledger.js +5 -1
  71. package/src/utils/thinking-validators.js +27 -80
  72. package/src/utils/validators.js +2 -3
@@ -32,18 +32,39 @@ function createMirrorState() {
32
32
  receivingReported: false,
33
33
  output: '', // accumulated assistant text
34
34
  seenReasoningParts: new Map(), // partId -> last captured reasoning length
35
- reasoningOutput: '', // accumulated reasoning text (promoted to output only if no text part arrives)
35
+ reasoningOutput: '', // accumulated reasoning text (promoted to output when no text part has arrived; see promotedOutput)
36
36
  usageByMsg: new Map(), // msgId -> {tokens, cost}
37
+ // #218 PR 3: three facts about the LAST assistant message in the snapshot
38
+ // -- its `finish` (stamped at finalization, beside tokens/cost), whether it
39
+ // carries answer text, whether it carries reasoning -- the whole input of
40
+ // the death test in utils/output-length.js. Per MESSAGE on purpose: `output`
41
+ // above accumulates across a tool loop's messages and would let earlier
42
+ // text hide a final length stop, or earlier promoted reasoning condemn a
43
+ // later message that answered (council #232 r1 B2/D1).
44
+ lastAssistantFinish: null,
45
+ lastAssistantHasText: false,
46
+ lastAssistantHasReasoning: false,
47
+ promotedOutput: '', // the reasoning a promotion put into `output` as a stand-in; the first real answer text on a later message replaces it (council #232 r1)
37
48
  };
38
49
  }
39
50
 
40
51
  /**
41
- * Capture one assistant message's usage snapshot into `state.usageByMsg`.
52
+ * Capture one assistant message's usage snapshot AND its finish into the state.
42
53
  * The poll loop re-reads ALL messages every poll, so the latest snapshot per
43
54
  * message id wins (keyed Map, never additive) — see pricing.sumPerMessageUsage.
44
55
  * @returns {boolean} true when this message carried a usage payload
45
56
  */
46
57
  function captureMsgUsage(msg, state) {
58
+ // #218 PR 3: `finish` was observed beside tokens/cost on every probe L row, so
59
+ // both mirror passes record it here; the last assistant message in the
60
+ // snapshot wins, and one still streaming (no finish yet) resets it to null; '' counts as none (council #232 r3 C1; mutant "EMPTYFINISH").
61
+ // The two part flags are read off THIS message's parts, never off `output`
62
+ // (council #232 r1 B2/D1). Named mutants "NOFINISH", "TEXTOFFOUTPUT"
63
+ // (tests/conversation-mirror.test.js).
64
+ state.lastAssistantFinish = (typeof msg.info.finish === 'string' && msg.info.finish.length > 0) ? msg.info.finish : null;
65
+ const parts = Array.isArray(msg.parts) ? msg.parts : [];
66
+ state.lastAssistantHasText = parts.some((p) => p && p.type === 'text' && typeof p.text === 'string' && p.text.trim().length > 0);
67
+ state.lastAssistantHasReasoning = parts.some((p) => p && p.type === 'reasoning' && typeof p.text === 'string' && p.text.length > 0);
47
68
  if (msg.info.tokens || typeof msg.info.cost === 'number') {
48
69
  state.usageByMsg.set(msg.info.id, { tokens: msg.info.tokens, cost: msg.info.cost });
49
70
  return true;
@@ -52,9 +73,9 @@ function captureMsgUsage(msg, state) {
52
73
  }
53
74
 
54
75
  /**
55
- * USAGE-ONLY mirror pass (v4.4 B1). Captures `info.tokens`/`info.cost` from a
56
- * fresh getMessages() snapshot and NOTHING else — no appendLines, no
57
- * `state.output` growth, no progress updates, no pending-tool bookkeeping.
76
+ * USAGE-ONLY mirror pass (v4.4 B1). Captures `info.tokens`/`info.cost` and, since #218 PR 3,
77
+ * the last assistant message's `finish` — from a fresh getMessages() snapshot and NOTHING else —
78
+ * no appendLines, no `state.output` growth, no progress updates, no pending-tool bookkeeping.
58
79
  *
59
80
  * This exists because the headless poll loop's fast-path exits (trailing fold
60
81
  * marker, SDK `idle`) break BEFORE OpenCode stamps usage at finalization, so a
@@ -63,7 +84,7 @@ function captureMsgUsage(msg, state) {
63
84
  * conversation.jsonl a second time; this function cannot, because it never
64
85
  * touches seenTextParts/output at all.
65
86
  * @param {Array} messages getMessages() snapshot
66
- * @param {object} state from createMirrorState() (only usageByMsg is mutated)
87
+ * @param {object} state from createMirrorState() (only usageByMsg and the three lastAssistant* facts are mutated)
67
88
  * @returns {number} count of messages whose usage was captured
68
89
  */
69
90
  function mirrorUsageOnly(messages, state) {
@@ -138,6 +159,9 @@ function mirrorMessages(messages, state, opts = {}) {
138
159
  if (part.text.length > prevLen) {
139
160
  // Append only the new portion (handles streaming growth)
140
161
  const newText = part.text.slice(prevLen);
162
+ // The first non-whitespace text replaces the stand-in; output restarts from the answer;
163
+ // conversation.jsonl keeps its reasoning. KEEPPROMOTED; WHITESPACERESET drops the trim.
164
+ if (state.promotedOutput && newText.trim().length > 0) { state.output = ''; state.promotedOutput = ''; }
141
165
  state.output += newText;
142
166
  state.seenTextParts.set(partId, part.text.length);
143
167
  appendLines.push({ role: 'assistant', content: newText, timestamp: now() });
@@ -244,12 +268,12 @@ function mirrorMessages(messages, state, opts = {}) {
244
268
  const lastAssistant = list.filter(m => m.info && m.info.role === 'assistant').pop();
245
269
  assistantFinished = !!(lastAssistant && lastAssistant.info.time && lastAssistant.info.time.completed);
246
270
 
247
- // Reasoning-only fallback: if the assistant finished but emitted only reasoning
248
- // parts (no visible text), promote the reasoning text to `output` so the headless
249
- // completion gates fire and the answer isn't lost as "No Output". Runs once — once
250
- // `output` is non-empty this is skipped on subsequent polls.
271
+ // Reasoning-only fallback: if the assistant finished but emitted only reasoning parts (no
272
+ // visible text), promote the reasoning text to `output` so the headless completion gates fire
273
+ // and the answer isn't lost as "No Output". Runs once — a non-empty `output` skips it on later
274
+ // polls. `promotedOutput` records the stand-in, which the first real text part replaces above.
251
275
  if (assistantFinished && !state.output && state.reasoningOutput) {
252
- state.output = state.reasoningOutput;
276
+ state.output = state.promotedOutput = state.reasoningOutput;
253
277
  appendLines.push({ role: 'assistant', content: state.reasoningOutput, timestamp: now() });
254
278
  }
255
279
 
@@ -47,6 +47,7 @@ function recordAttemptSpend({ doc, leg, currentModel, legId, waveId, project, at
47
47
  project, attempt: leg && leg.attempt, substitutedFor: leg && leg.substitutedFor,
48
48
  retryOfWaveId: leg && leg.retryOfWaveId,
49
49
  tag: (leg && leg.tag) || null,
50
+ finish: doc.finish, variant: doc.variant, // #218 PR 3 / PR 4: appendSpend keeps each only when it is a string (named mutant "LEGROWNOVARIANT", tests/sidecar/fanout.test.js)
50
51
  };
51
52
  if (attempt > 0) { row.attempt = attempt; row.substitutedFor = originalModel; }
52
53
  appendSpend(row, deps.spendDir ? { dir: deps.spendDir } : undefined);
@@ -76,7 +76,7 @@ function buildRoutingFailureLeg({ leg, legId, waveId, quiet }) {
76
76
  * Adds `.reason` (alias of buildRunResult's `.error`) and `.legId` so the
77
77
  * fallback loop reads a stable shape without re-deriving them.
78
78
  */
79
- async function runSingleAttempt({ leg, legId, waveId, project, directory, follow, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet, foldNonce, noOutputBackstopMs }) {
79
+ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, variant, quiet, foldNonce, noOutputBackstopMs }) {
80
80
  const { IdleWatchdog } = require('../utils/idle-watchdog');
81
81
  const { markAborted } = require('../utils/session-abort');
82
82
  const { runHeadless } = require('../headless');
@@ -123,7 +123,7 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
123
123
  result = await runHeadless(
124
124
  leg.model, systemPrompt, userMessage, legId, project,
125
125
  timeoutMs, agent || 'build',
126
- { client, server, watchdog, summaryLength, reasoning, nonce: foldNonce, directory, noOutputBackstopMs }
126
+ { client, server, watchdog, summaryLength, variant, nonce: foldNonce, directory, noOutputBackstopMs }
127
127
  );
128
128
  } catch (err) {
129
129
  result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId: legId };
@@ -215,6 +215,14 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
215
215
  // nothing whichever one holds: it is one `&&` against a value already in a
216
216
  // register, and it is dead code if `result` is truly always assigned.
217
217
  ttftMs: result && isMeasuredTtft(result.ttftMs) ? result.ttftMs : undefined,
218
+ // #218 PR 3: the engine's `finish` for the leg's last assistant message
219
+ // ('length' = stopped at the reservation), emit-when-set like ttftMs above.
220
+ finish: (result && typeof result.finish === 'string') ? result.finish : undefined,
221
+ // #218 PR 4: the effort level SENT (emit-when-sent) and whether the engine's
222
+ // catalogue knew the model when it was sent. Named mutant "LEGVARIANTDROPPED"
223
+ // (tests/sidecar/fanout.test.js).
224
+ variant: (result && typeof result.variant === 'string') ? result.variant : undefined,
225
+ variantUnverified: (result && result.variantUnverified === true) ? true : undefined,
218
226
  };
219
227
  let finalMeta = legPatch;
220
228
  if (legDir) {
@@ -246,7 +246,7 @@ async function runFanout(options) {
246
246
  HEARTBEAT_INTERVAL
247
247
  );
248
248
  const timeoutMs = (options.timeout || 15) * 60 * 1000;
249
- const reasoning = options.thinking ? { effort: options.thinking } : undefined;
249
+ const variant = options.thinking || undefined; // #218 PR 4: one level for every leg, sent as the engine's `variant` field (named mutant "FANOUTVARIANTDROPPED", tests/sidecar/fanout.test.js: drop `variant` from the runLeg args)
250
250
  let legDocs;
251
251
  try {
252
252
  // retryContexts/retryOfWaveId (v4.3 Task 19): absent on a normal wave, so
@@ -261,7 +261,7 @@ async function runFanout(options) {
261
261
  systemPrompt: saved ? rc.systemPrompt : systemPrompt,
262
262
  userMessage: saved ? rc.userMessage : userMessage,
263
263
  timeoutMs, agent: options.agent, client, server,
264
- summaryLength: options.summaryLength, reasoning, quiet: options.quiet,
264
+ summaryLength: options.summaryLength, variant, quiet: options.quiet,
265
265
  foldNonce, directory: options.directory, follow,
266
266
  fallback: options.fallback, catalog: options.catalog, noOutputBackstopMs: options.noOutputBackstopMs,
267
267
  });
@@ -18,6 +18,7 @@ const { canonicalProjectPath } = require('../utils/project-path');
18
18
  const { ensureElectron } = require('./electron-ensure');
19
19
  const { writeProgress } = require('./progress');
20
20
  const { getElectronPath, buildElectronEnv, handleElectronProcess } = require('./interactive-process');
21
+ const { readOutputBudgetSafe } = require('../headless');
21
22
 
22
23
  /** Run sidecar in interactive mode (Electron GUI) */
23
24
  async function runInteractive(model, systemPrompt, userMessage, taskId, project, options = {}) {
@@ -33,7 +34,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
33
34
  };
34
35
  }
35
36
 
36
- const { agent, isResume, conversation, mcp, reasoning, opencodeSessionId, client, foldNonce } = options;
37
+ const { agent, isResume, conversation, mcp, variant, opencodeSessionId, client, foldNonce } = options;
37
38
 
38
39
  // F6c: mirror headless's lifecycle stages (best-effort — a write failure must
39
40
  // never break the GUI) so the heartbeat/status never read "Starting up...".
@@ -70,6 +71,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
70
71
  };
71
72
  }
72
73
 
74
+ let sent = null; // #218 PR 4 whole-branch review (EP-4): what sendPrompt SENT, for the record
73
75
  // Create or reconnect to session
74
76
  let sessionId;
75
77
  try {
@@ -93,9 +95,29 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
93
95
 
94
96
  // Always set agent — defaults to 'chat' when not specified
95
97
  promptOptions.agent = agentConfig.agent;
96
- if (reasoning) { promptOptions.reasoning = reasoning; }
97
-
98
- await sendPromptAsync(ocClient, sessionId, promptOptions);
98
+ // #218 PR 4: the engine's `variant` field, validated in sendPrompt; the
99
+ // spawn-time budget rides the handle (PR 3). A refusal lands in the catch
100
+ // below as "Session setup failed: VARIANT_…" — nothing was sent.
101
+ // Named mutants "GUIBUDGETDROPPED" / "GUIREFUSALPREFIX" (tests/sidecar/interactive-variant.test.js).
102
+ if (variant) {
103
+ promptOptions.variant = variant;
104
+ promptOptions.outputBudget = readOutputBudgetSafe(server); // council #235 r1 (A1): the SAME reader headless uses (src/headless.js :: readOutputBudgetSafe) — the handle's spawn value, else config
105
+ }
106
+
107
+ const promptResult = await sendPromptAsync(ocClient, sessionId, promptOptions);
108
+ sent = promptResult && promptResult.sentVariant;
109
+ if (sent && !sent.verified) {
110
+ const { formatUnverifiedVariantNote } = require('../utils/engine-variants');
111
+ const note = formatUnverifiedVariantNote({ model, variant: sent.variant, waitedMs: sent.waitedMs, unreadable: sent.unreadable });
112
+ logger.warn('Variant sent unverified', { taskId, sessionId, note });
113
+ // council #235 r2 (B2): logger.warn is DROPPED at the shipped default
114
+ // (LOG_LEVEL defaults to 'error', utils/logger.js), so the structured line alone
115
+ // told the user nothing — the silent degrade the product principle forbids, and the
116
+ // same invisibility this release cites against 4.9.3's silent adjustment. stderr
117
+ // carries it in every mode; stdout keeps the run document intact. Named mutant
118
+ // "UNVERIFIEDNOTICESILENT": drop the stderr write.
119
+ process.stderr.write(`Notice: ${note}\n`);
120
+ }
99
121
  progressStage('prompt_sent');
100
122
  }
101
123
  logger.debug('Interactive session ready', { sessionId, isResume: !!isResume });
@@ -201,6 +223,11 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
201
223
  } catch (err) { logger.debug('mirror stop failed', { error: err.message }); }
202
224
  try { await server.close(); } catch { /* best-effort */ }
203
225
  logger.debug('OpenCode server closed after Electron exit');
226
+ // #218 PR 4 whole-branch review (EP-4/REC-2/PRT-2): the level SENT rides the interactive
227
+ // result too (emit-when-sent, the derivation headless.js:811-814 makes), so start.js's
228
+ // writers stamp `variant` / `variantUnverified` for the default GUI mode as well.
229
+ // Named mutant "INTERACTIVEVARIANTDROPPED" (tests/sidecar/interactive-variant.test.js).
230
+ if (sent) { result.variant = sent.variant; if (!sent.verified) { result.variantUnverified = true; } }
204
231
  result.opencodeSessionId = sessionId;
205
232
  resolve(result);
206
233
  });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @module models-ceiling-line
3
+ * The one `Ceilings:` line `amicus models --refresh` prints (#218 P3).
4
+ *
5
+ * It is an honest report of where the direct-provider context/output ceilings
6
+ * came from, or why they did not come at all — filled, already complete, still
7
+ * missing a number, failed, or skipped.
8
+ *
9
+ * Extracted from `models.js` (council #230 r4) because that file sat at 298 of
10
+ * the 300-line budget and this round adds two more branches to the formatter.
11
+ * It owns the WORDING only: the outcome object it renders is built by
12
+ * `src/utils/model-ceilings-modelsdev.js` and persisted by
13
+ * `src/utils/model-catalog.js` as the cache document's `ceilingEnrichment`.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ /**
19
+ * How a ceiling-enrichment failure is worded, keyed by its `failure.reason`
20
+ * (council #230 D4/C1). `http-status`, `parse-error`, `too-large` and
21
+ * `bad-shape` all mean models.dev ANSWERED; `exception` — and any reason a later
22
+ * failure invents — is a local bug, so it falls through to a neutral lead.
23
+ */
24
+ const CEILING_FAILURE_LEAD = {
25
+ timeout: 'models.dev unreachable',
26
+ 'network-error': 'models.dev unreachable',
27
+ 'http-status': 'models.dev answered but could not be used',
28
+ 'parse-error': 'models.dev answered but could not be used',
29
+ 'too-large': 'models.dev answered but could not be used',
30
+ 'bad-shape': 'models.dev answered but could not be used',
31
+ };
32
+
33
+ /**
34
+ * #218 P3: one honest line about where the direct-provider ceilings came from.
35
+ * @param {object|null} e the persisted `ceilingEnrichment` outcome
36
+ * @returns {string}
37
+ */
38
+ function fmtCeilingLine(e) {
39
+ // Unreachable in production after #218 P3 (every successful refresh persists a
40
+ // ceilingEnrichment object); kept for a hand-built or pre-field cache doc.
41
+ if (!e) { return 'Ceilings: not attempted'; }
42
+ if (e.failure) {
43
+ const f = e.failure;
44
+ const why = f.reason + (f.status ? ` ${f.status}` : '') + (f.detail ? `: ${f.detail}` : '');
45
+ const lead = CEILING_FAILURE_LEAD[f.reason] || 'ceiling enrichment failed';
46
+ // #218 PR 4 (whole-branch review VCMD-4): the flag never reaches a direct openai row — M5/M13/M22, the same fact the disabled literal below states.
47
+ return `Ceilings: ${lead} (${why}); rows without a ceiling get an outputBudget through the engine flag alone, clamped only where the engine's own catalog knows the model; direct openai rows send no output reservation at all (#218 PR 4, M5/M13/M22)`;
48
+ }
49
+ // A skip is not a failure and not a fill: naming which one it was is the
50
+ // difference between "you turned this off" and "there was nothing to do".
51
+ if (e.skipped === 'disabled') {
52
+ // Named, not "direct routes": Google publishes its own ceiling first-party
53
+ // and OpenRouter rows keep OpenRouter's. Since PR 2 the budget still
54
+ // reaches the anthropic/deepseek rows through the engine flag, clamped by
55
+ // the engine's own catalog (probe K5/K12); it never reaches a direct
56
+ // openai row at all (#218 PR 4, M5/M13/M22).
57
+ return 'Ceilings: models.dev lookup disabled (modelsDevCeilings: false); anthropic/deepseek direct rows carry no ceiling here and are clamped by the engine\'s own catalog instead; direct openai rows send no output reservation at all (#218 PR 4) (Google publishes its own ceiling and OpenRouter rows keep OpenRouter\'s)';
58
+ }
59
+ if (e.skipped === 'nothing-to-fill') {
60
+ // NOT "every row": routers, local rows and malformed rows are not
61
+ // candidates and are never asked about (council #230 C2).
62
+ return 'Ceilings: nothing to fill (no candidate row is missing a number)';
63
+ }
64
+ // `?? 0`: a hand-built or pre-field cache doc can carry a partial object, and
65
+ // `undefined already complete` would be a worse lie than a zero. `stillMissing`
66
+ // deliberately overlaps the other counters — it is the STATE the pass left,
67
+ // and it is the number that says whether outputBudget can clamp those rows.
68
+ return `Ceilings: ${e.filled ?? 0} rows filled from models.dev (${e.alreadyKnown ?? 0} already complete, ` +
69
+ `${e.unknown ?? 0} unknown to models.dev, ${e.stillMissing ?? 0} still missing a number)`;
70
+ }
71
+
72
+ module.exports = { fmtCeilingLine };
@@ -22,6 +22,7 @@ const { pickCurrent } = require('../utils/quick-picks');
22
22
  const { probeStoredAliases, selectStoredAliases } = require('./models-probe');
23
23
  const { DEFAULT_MAX_LEGS } = require('./fanout-validate');
24
24
  const { fmtRow, fmtGatewayFinding, fmtProbeLine, fmtProviderFailure } = require('./models-render');
25
+ const { fmtCeilingLine } = require('./models-ceiling-line');
25
26
 
26
27
  const CHECK_EXIT_CAP = 100;
27
28
 
@@ -87,7 +88,7 @@ function fmtLiveSkipped(reason) {
87
88
 
88
89
  async function runRefresh(args) {
89
90
  const models = await refreshCatalog();
90
- const { fetchedAt, lastRefreshAttempt, lastRefreshError } = await getCatalogInfo({ maxAgeMs: Number.POSITIVE_INFINITY });
91
+ const { fetchedAt, lastRefreshAttempt, lastRefreshError, ceilingEnrichment } = await getCatalogInfo({ maxAgeMs: Number.POSITIVE_INFINITY });
91
92
  // --refresh short-circuits --check below (args.check is guaranteed true here) — must announce, not silently skip.
92
93
  if (args.live) {
93
94
  const line = fmtLiveSkipped('refresh-precedes-check');
@@ -95,7 +96,7 @@ async function runRefresh(args) {
95
96
  }
96
97
  if (args.json) {
97
98
  process.stdout.write(JSON.stringify(buildCatalogDoc({
98
- models, fetchedAt, refreshed: true, lastRefreshAttempt, lastRefreshError
99
+ models, fetchedAt, refreshed: true, lastRefreshAttempt, lastRefreshError, ceilingEnrichment
99
100
  }), null, 2) + '\n');
100
101
  return models.length === 0 && !fetchedAt ? 1 : 0;
101
102
  }
@@ -112,6 +113,7 @@ async function runRefresh(args) {
112
113
  return 1; // no cache at all: a real failure
113
114
  }
114
115
  process.stdout.write(`Refreshed catalog: ${models.length} models.\n`);
116
+ process.stdout.write(fmtCeilingLine(ceilingEnrichment) + '\n');
115
117
  process.stdout.write(`Cache: ${catalogPath()}\n`);
116
118
  return 0;
117
119
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @module reopen-notices
3
+ * The stderr Notice a reopen owes the user for the effort level it does NOT carry (#218 PR 4, council #235 r5 J1/A3).
4
+ * A session started with `--thinking <level>` sends no variant on any resumed or
5
+ * continued leg, and since council #235 r2 both commands REJECT the flag, so the
6
+ * user cannot ask for one there either. Level inheritance was scoped out of this PR
7
+ * deliberately and stays out (filed, not built) — but the silence is the defect: it
8
+ * is the same mid-conversation degrade this release cites against 4.9.3, and the
9
+ * project's rule is that a level which will not take effect says so. Named mutants
10
+ * "RESUMELEVELSILENT" / "CONTINUELEVELSILENT" (tests/sidecar/reopen-thinking-notice.test.js):
11
+ * drop the call at either reopen site.
12
+ *
13
+ * Notices go to STDERR only — stdout carries the `--json` run document and the
14
+ * fold summary, and nothing here may perturb either (same rule as the unverified
15
+ * note in src/headless.js and src/sidecar/interactive.js).
16
+ */
17
+
18
+ const { collapseExcerpt } = require('../utils/text-sanitize');
19
+
20
+ /** Longest LEVEL echoed from on-disk metadata into a one-line Notice — a level is one word. */
21
+ const MAX_LEVEL_CHARS = 40;
22
+ /**
23
+ * Longest TASK ID echoed. It is not an attacker-shaped fragment: `validators.js ::
24
+ * TASK_ID_PATTERN` is `/^[a-zA-Z0-9_-]{1,64}$/`, which already excludes every character the
25
+ * sanitizer strips — so the cap is what a VALID id can be. Capping it at the LEVEL's 40
26
+ * named a session that does not exist (council #235 r5 wave 6 repair).
27
+ */
28
+ const MAX_TASK_ID_CHARS = 64;
29
+
30
+ /**
31
+ * Collapse an on-disk fragment to one safe line.
32
+ * metadata.json is a FILE — a hand-edited or corrupted `thinking` value must not be
33
+ * able to forge a second `Notice:` line or smuggle control characters into a terminal
34
+ * (the hazard src/utils/alias-shadow.js :: safeFragment was written against).
35
+ *
36
+ * The SANITIZING is the house sanitizer's: `utils/text-sanitize.js :: collapseExcerpt` is the
37
+ * only one ("a second implementation would be a second set of holes", and the holes are the
38
+ * point — ANSI sequences and the PRINTABLE-range bidi controls are classes a private
39
+ * control-character regex cannot see, and this module quotes both straight out of a file).
40
+ * The fence/tag defang here is ADDITIVE and runs BEFORE that pass, so the house whitespace
41
+ * collapse and cap still govern the result: it adds a class, it never replaces one. Same
42
+ * shape as src/utils/alias-shadow.js :: safeFragment (council #235 r5 wave 6 repair).
43
+ * @param {unknown} value
44
+ * @param {number} maxChars
45
+ * @returns {string}
46
+ */
47
+ function safeFragment(value, maxChars) {
48
+ return collapseExcerpt(String(value).replace(/[`<>]/g, ' '), maxChars);
49
+ }
50
+
51
+ /**
52
+ * The value 4.9.3 and earlier stamped on EVERY session's metadata whether or not the flag was
53
+ * typed — and never sent (probe F1). It is the one recorded level whose provenance is unknowable
54
+ * from disk, so the line SAYS that rather than reporting it as something the user asked for.
55
+ */
56
+ const LEGACY_UNCONDITIONAL_STAMP = 'medium';
57
+
58
+ /**
59
+ * The Notice line for a reopen that drops the session's recorded effort level, or null.
60
+ * Null whenever the session records no level — nothing was asked for, so nothing is dropped.
61
+ *
62
+ * It reports what the metadata RECORDS, not what was typed (council #235 r5 wave 6 repair).
63
+ * Those are not the same proposition: 4.9.3 and earlier wrote `thinking: 'medium'` on every
64
+ * session, flag or no flag, so the entire on-disk session history at upgrade time reaches this
65
+ * line carrying a level nobody requested — and for those legs nothing degraded, because that
66
+ * `medium` was never sent either. Nothing on disk tells the two apart (the same reason this
67
+ * release refuses a pack migration), so the line states the record and names the ambiguity.
68
+ * @param {{taskId: string, level: unknown, kind: 'resume'|'continue'}} a
69
+ * @returns {string|null}
70
+ */
71
+ function formatDroppedLevelNotice({ taskId, level, kind }) {
72
+ if (typeof level !== 'string') { return null; }
73
+ const shown = safeFragment(level, MAX_LEVEL_CHARS);
74
+ if (!shown) { return null; }
75
+ // Named mutant "NOTICECLAIMSINTENT": say "was started with" again, or drop this clause.
76
+ const provenance = shown === LEGACY_UNCONDITIONAL_STAMP
77
+ ? ' (4.9.3 and earlier recorded medium on every session, typed or not, and never sent it)'
78
+ : '';
79
+ const what = kind === 'continue'
80
+ ? 'this continuation opens a NEW session and sends no effort level, so it runs at the provider\'s default — a level belongs on the `start` that opens a session and is not carried across a reopen'
81
+ : 'this resumed leg sends no effort level and runs at the provider\'s default — a level is not carried across a reopen';
82
+ return `Notice: session ${safeFragment(taskId, MAX_TASK_ID_CHARS)} records --thinking ${shown}${provenance}; ${what}`;
83
+ }
84
+
85
+ /**
86
+ * Write that Notice to stderr when the session recorded a level. No-op otherwise.
87
+ * @param {object} metadata - the session metadata whose `thinking` is read (resume: the session's own; continue: the PARENT's)
88
+ * @param {{taskId: string, kind: 'resume'|'continue'}} a
89
+ * @returns {string|null} the line written, or null when nothing was written
90
+ */
91
+ function noticeDroppedLevel(metadata, { taskId, kind }) {
92
+ const note = formatDroppedLevelNotice({ taskId, level: metadata && metadata.thinking, kind });
93
+ if (note) { process.stderr.write(`${note}\n`); }
94
+ return note;
95
+ }
96
+
97
+ module.exports = { formatDroppedLevelNotice, noticeDroppedLevel };
@@ -23,8 +23,9 @@ function finalizeSpendForReopen({ taskId, model, mode, op, result, status, proje
23
23
  const { gatewayOf } = require('../utils/gateway-router');
24
24
  const gateway = metadata.gateway || gatewayOf(model);
25
25
  // v4.7.1 Task 7 D16: null-not-absent, the OPPOSITE convention from
26
- // metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:237.
27
- appendSpend({ taskId, model, mode, usage, op, status, project, gateway, tag: metadata.tag || null }, ctx);
26
+ // metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:240.
27
+ // #218 PR 3/PR 4: `finish` and `variant` are the OPPOSITE — absent-not-null: appendSpend writes each key only when it is a string. Named mutant "SOLOROWNOVARIANT" (tests/continue-resume-spend.test.js): drop `variant`.
28
+ appendSpend({ taskId, model, mode, usage, op, status, project, gateway, tag: metadata.tag || null, finish: result && result.finish, variant: result && result.variant }, ctx);
28
29
  } catch { /* best-effort */ }
29
30
  }
30
31
  return { usage };
@@ -16,6 +16,7 @@ const {
16
16
  checkSessionLiveness
17
17
  } = require('./session-utils');
18
18
  const { acquireLock, releaseLock } = require('../utils/session-lock');
19
+ const { noticeDroppedLevel } = require('./reopen-notices');
19
20
  const { runHeadless } = require('../headless');
20
21
  const { extractNonceFromText, generateFoldNonce, stripFoldMarkers } = require('../utils/fold-marker');
21
22
  const { logger } = require('../utils/logger');
@@ -41,7 +42,8 @@ function loadInitialContext(sessionDir) {
41
42
  /** Check for file drift - files that were read may have changed */
42
43
  function checkFileDrift(metadata, project) {
43
44
  const filesRead = metadata.filesRead || [];
44
- const lastActivity = metadata.completedAt || metadata.createdAt;
45
+ // council #235 r5 (J2/A4): the previous attempt's terminal timestamps are all deleted on every running write (updateSessionStatus below), so this — their one legitimate reader — falls back through them. `abortedAt` is a terminal stamp of that attempt and later than its start, so it is read first (wave 6 repair); `resumedAt` is when a crashed attempt STARTED, strictly more accurate than the previous attempt's completion. Named mutants "DRIFTNORESUMEDAT" / "DRIFTNOABORTEDAT": drop either middle term.
46
+ const lastActivity = metadata.completedAt || metadata.abortedAt || metadata.resumedAt || metadata.createdAt;
45
47
  const lastActivityTime = new Date(lastActivity).getTime();
46
48
  const changedFiles = [];
47
49
 
@@ -112,6 +114,13 @@ function updateSessionStatus(sessionDir, status) {
112
114
  meta.status = status;
113
115
  if (status === 'running') {
114
116
  meta.resumedAt = new Date().toISOString();
117
+ // #218 PR 4 whole-branch review (REC-3): the per-attempt fields are this attempt's to stamp
118
+ // — an abort or crash of the resumed run must not ship the previous attempt's as its own
119
+ // (the terminal writers preserve every key they do not set). Named mutant
120
+ // "RESUMESTALEVARIANT" (tests/sidecar/resume.test.js): drop the three deletes.
121
+ // council #235 r5 (J2/A4): `reason` and the terminal timestamps are stamped ONLY by a terminal writer, so an attempt that never reaches one must not inherit the previous attempt's — a resume that crashes mid-attempt used to leave `status: 'running'` beside the last failure's reason and completion time, and both are read (src/utils/result-schema.js reports `metadata.reason` for every non-complete status and `completedAt || abortedAt` as the end; the MCP server prints the reason and adds `crashedAt` to that chain). ALL THREE timestamps go (wave 6 repair): `abortedAt` is the one an `amicus abort` writes and `completedAt` is never written on that path, so clearing only `completedAt` left the defect live on the commonest precursor to a resume — and, for an attempt that stamped both, made the reported end fall through to the OLDER one. Named mutants "RESUMESTALEREASON" (drop `reason`/`completedAt`) and "RESUMESTALEABORTEDAT" (drop `abortedAt`/`crashedAt`).
122
+ delete meta.finish; delete meta.variant; delete meta.variantUnverified;
123
+ delete meta.reason; delete meta.completedAt; delete meta.abortedAt; delete meta.crashedAt;
115
124
  }
116
125
  writeFileAtomic(metaPath, JSON.stringify(meta, null, 2));
117
126
  return meta;
@@ -135,6 +144,7 @@ async function resumeSidecar(options) {
135
144
 
136
145
  // Load previous session data
137
146
  const metadata = loadSessionMetadata(sessionDir);
147
+ noticeDroppedLevel(metadata, { taskId, kind: 'resume' }); // council #235 r5 (J1/A3): this leg sends no variant and `resume` rejects --thinking, so a session started with a level silently degrades to the provider's default — the very degrade this release cites against 4.9.3. Inheritance stays filed, not built; the silence does not. Named mutant "RESUMELEVELSILENT" (tests/sidecar/reopen-thinking-notice.test.js).
138
148
  const systemPrompt = loadInitialContext(sessionDir);
139
149
 
140
150
  // Dead-process detection: log if the previous process is no longer alive
@@ -242,11 +252,14 @@ async function resumeSidecar(options) {
242
252
  if (terminal.status === 'error') {
243
253
  updatedMetadata.status = 'error';
244
254
  updatedMetadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
255
+ if (result && typeof result.finish === 'string') { updatedMetadata.finish = result.finish; } else { delete updatedMetadata.finish; } // #218 PR 3: emit-when-set; a stale one is removed (council #232 r1 B1)
256
+ if (result && typeof result.variant === 'string') { updatedMetadata.variant = result.variant; } else { delete updatedMetadata.variant; } // #218 PR 4: same rule as finish (named mutant "RESUMEERRORNOVARIANT", tests/continue-resume-spend.test.js)
257
+ if (result && result.variantUnverified === true) { updatedMetadata.variantUnverified = true; } else { delete updatedMetadata.variantUnverified; }
245
258
  updatedMetadata.completedAt = new Date().toISOString();
246
259
  writeFileAtomic(metaPath, JSON.stringify(updatedMetadata, null, 2), { mode: 0o600 });
247
260
  logger.error('Resume completed with error', { taskId, error: updatedMetadata.reason });
248
261
  } else {
249
- finalizeSession(sessionDir, summary, project, updatedMetadata, { quietStdout: json, status: terminal.status });
262
+ finalizeSession(sessionDir, summary, project, updatedMetadata, { quietStdout: json, status: terminal.status, finish: result && result.finish, variant: result && result.variant, variantUnverified: result && result.variantUnverified }); // named mutant "RESUMEVARIANTDROPPED" (tests/continue-resume-spend.test.js)
250
263
  }
251
264
  // v4.3: attribute resume spend (C9/E4). Reload metadata, write usage + append
252
265
  // a ledger row (status: statusFromResult, matching start.js — not terminal.status).
@@ -51,6 +51,9 @@ function finalizeHeadlessResult(sessionDir, result, project, metadata) {
51
51
  fs.writeFileSync(SessionPaths.summaryFile(sessionDir), result && result.summary ? result.summary : '', { mode: 0o600 });
52
52
  metadata.status = 'error';
53
53
  metadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
54
+ if (result && typeof result.finish === 'string') { metadata.finish = result.finish; } else { delete metadata.finish; } // #218 PR 3: emit-when-set; a stale one is removed (council #232 r1 B1)
55
+ if (result && typeof result.variant === 'string') { metadata.variant = result.variant; } else { delete metadata.variant; } // #218 PR 4: same rule as finish (named mutant "SHAREDNOVARIANT", tests/shared-server-finalize.test.js)
56
+ if (result && result.variantUnverified === true) { metadata.variantUnverified = true; } else { delete metadata.variantUnverified; }
54
57
  metadata.completedAt = new Date().toISOString();
55
58
  writeFileAtomic(
56
59
  path.join(sessionDir, 'metadata.json'),
@@ -61,7 +64,7 @@ function finalizeHeadlessResult(sessionDir, result, project, metadata) {
61
64
  }
62
65
  // complete / timed-out / aborted: persist the (possibly partial) summary with
63
66
  // the resolved status. Explicit status means the #36 guard won't re-classify.
64
- finalizeSession(sessionDir, (result && result.summary) || '', project, metadata, { status: terminal.status });
67
+ finalizeSession(sessionDir, (result && result.summary) || '', project, metadata, { status: terminal.status, finish: result && result.finish, variant: result && result.variant, variantUnverified: result && result.variantUnverified });
65
68
  }
66
69
 
67
70
  module.exports = { resolveTerminalState, finalizeHeadlessResult };
@@ -70,7 +70,7 @@ function saveInitialContext(sessionDir, systemPrompt, userMessage) {
70
70
  fs.writeFileSync(SessionPaths.contextFile(sessionDir), content, { mode: 0o600 });
71
71
  }
72
72
 
73
- /** Finalize session - detect conflicts, save summary, update metadata */
73
+ /** Finalize session - detect conflicts, save summary, update metadata. opts.finish (#218 PR 3) stamps metadata.finish when set and REMOVES a prior one otherwise; opts.variant / opts.variantUnverified (#218 PR 4) follow the same rule — a resumed run reuses the same metadata and must not inherit the last attempt's finish (council #232 r1 B1). */
74
74
  function finalizeSession(sessionDir, summary, project, metadata, opts = {}) {
75
75
  const metaPath = SessionPaths.metadataFile(sessionDir);
76
76
 
@@ -102,6 +102,10 @@ function finalizeSession(sessionDir, summary, project, metadata, opts = {}) {
102
102
  // summary must never silently default to 'complete' — that hid errored/empty
103
103
  // shared-server runs behind a 0-byte summary and a false success.
104
104
  const hasSummary = typeof summary === 'string' && summary.trim().length > 0;
105
+ if (typeof opts.finish === 'string') { metadata.finish = opts.finish; } else { delete metadata.finish; }
106
+ // #218 PR 4: the effort level SENT and whether the engine's catalogue knew the model — the same emit-when-set / delete-when-absent rule as finish. Named mutants "SOLOVARIANTDROPPED" / "STALEVARIANT" (tests/sidecar/session-utils.test.js).
107
+ if (typeof opts.variant === 'string') { metadata.variant = opts.variant; } else { delete metadata.variant; }
108
+ if (opts.variantUnverified === true) { metadata.variantUnverified = true; } else { delete metadata.variantUnverified; }
105
109
  metadata.status = opts.status || (hasSummary ? 'complete' : 'error');
106
110
  metadata.completedAt = new Date().toISOString();
107
111
  writeFileAtomic(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
@@ -42,7 +42,7 @@ function createSessionMetadata(taskId, project, options) {
42
42
  briefing: effectiveBriefing,
43
43
  mode: isHeadless ? 'headless' : 'interactive',
44
44
  agent: agent || (isHeadless ? 'build' : 'chat'),
45
- thinking: thinking || 'medium',
45
+ ...(thinking ? { thinking } : {}), // #218 PR 4: emit-when-requested — 'medium' was never sent (probe F1), so a run with no --thinking runs at the provider's default and records none (named mutant "MEDIUMDEFAULT")
46
46
  status: 'running',
47
47
  pid: existing.pid || process.pid,
48
48
  createdAt: existing.createdAt || new Date().toISOString(),
@@ -125,7 +125,7 @@ async function startSidecar(options) {
125
125
  mcp, mcpConfig, clientType: client, noMcp, excludeMcp, projectDir: effectiveProject
126
126
  });
127
127
  const taskId = options.taskId || generateTaskId();
128
- const reasoning = thinking ? { effort: thinking } : undefined;
128
+ const variant = thinking || undefined; // #218 PR 4: the level itself is the engine's `variant` field (named mutant "STARTVARIANTDROPPED", tests/sidecar/start.test.js: drop `variant` from the two options objects below)
129
129
  // 15b.3: one nonce per run, generated BEFORE prompt construction so the
130
130
  // SAME value can be baked into the prompt's instruction (buildPrompts) and
131
131
  // handed to the detector (runHeadless.options.nonce / the GUI fold writer
@@ -158,7 +158,7 @@ async function startSidecar(options) {
158
158
  result = await runHeadless(
159
159
  model, systemPrompt, userMessage, taskId, effectiveProject,
160
160
  timeout * 60 * 1000, agent || 'build',
161
- { mcp: mcpServers, summaryLength, reasoning, port: opencodePort, nonce: foldNonce }
161
+ { mcp: mcpServers, summaryLength, variant, port: opencodePort, nonce: foldNonce }
162
162
  );
163
163
  } catch (err) {
164
164
  if (!json) { throw err; }
@@ -174,7 +174,7 @@ async function startSidecar(options) {
174
174
  logger.info('Launching interactive sidecar', { taskId, model, agent: effectiveAgent });
175
175
  result = await runInteractive(
176
176
  model, systemPrompt, userMessage, taskId, effectiveProject,
177
- { agent, mcp: mcpServers, reasoning, client, windowPosition: position, foldNonce }
177
+ { agent, mcp: mcpServers, variant, client, windowPosition: position, foldNonce }
178
178
  );
179
179
  summary = result.summary || '';
180
180
  if (result.error) { logger.error('Interactive task error', { taskId, error: result.error }); }
@@ -200,12 +200,15 @@ async function startSidecar(options) {
200
200
  if (terminal.status === 'error') {
201
201
  meta.status = 'error';
202
202
  meta.reason = (result && result.error) ? String(result.error) : 'Incomplete';
203
+ if (result && typeof result.finish === 'string') { meta.finish = result.finish; } // #218 PR 3: emit-when-set; a fresh session's metadata has no prior finish to remove (resume's does -- resume.js)
204
+ if (result && typeof result.variant === 'string') { meta.variant = result.variant; } // #218 PR 4: emit-when-set, like finish (named mutant "SOLOERRORNOVARIANT", tests/start-terminal-status.test.js)
205
+ if (result && result.variantUnverified === true) { meta.variantUnverified = true; }
203
206
  meta.completedAt = new Date().toISOString();
204
207
  writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
205
208
  logger.error('Session completed with error', { taskId, error: meta.reason });
206
209
  } else {
207
210
  // complete / timed-out / aborted: persist the (possibly partial) summary with the correct status.
208
- finalizeSession(sessDir, summary, effectiveProject, meta, { quietStdout: json, status: terminal.status });
211
+ finalizeSession(sessDir, summary, effectiveProject, meta, { quietStdout: json, status: terminal.status, finish: result && result.finish, variant: result && result.variant, variantUnverified: result && result.variantUnverified });
209
212
  }
210
213
 
211
214
  const { resolveUsage } = require('../utils/pricing');
@@ -223,6 +226,8 @@ async function startSidecar(options) {
223
226
  appendSpend({
224
227
  taskId, model, mode: effectiveHeadless ? 'headless' : 'interactive', usage: runUsage,
225
228
  op: 'start', status: statusFromResult(result), project: effectiveProject,
229
+ finish: result && result.finish, // #218 PR 3: appendSpend keeps it only when it is a string
230
+ variant: result && result.variant, // #218 PR 4: same emit-when-set rule (named mutant "SOLOROWNOVARIANT", tests/start-json.test.js)
226
231
  // ⚠️ DE-ROT: `metadata` is NOT in scope at startSidecar's finalize site — the objects
227
232
  // there are `meta` (createSessionMetadata result) and `m`; `metadata` is a local only
228
233
  // inside createSessionMetadata. Reading `metadata.gateway` throws a ReferenceError the
@@ -232,7 +237,7 @@ async function startSidecar(options) {
232
237
  // (To also attribute v4.2 'local': thread the resolved route gateway — dropped today at
233
238
  // cli-handlers-run.js:47 — into createSessionMetadata and read `meta.gateway`, as continue.js:111 does.)
234
239
  // v4.7 F8 D16: same in-scope-value rule as gateway above — `m` is the
235
- // just-re-read metadata (line 214), which carries `tag` when
240
+ // just-re-read metadata (line 215), which carries `tag` when
236
241
  // createSessionMetadata stored one (absent otherwise); `|| null` folds
237
242
  // that into spend-ledger.js's null-not-absent dim convention.
238
243
  tag: m.tag || null,