amicus 4.8.1 → 4.9.1

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 (117) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +316 -0
  3. package/README.md +8 -3
  4. package/docs/ROADMAP.md +101 -10
  5. package/docs/configuration.md +54 -4
  6. package/docs/council.md +102 -14
  7. package/docs/troubleshooting.md +9 -2
  8. package/docs/usage.md +128 -12
  9. package/electron/ipc-setup.js +41 -18
  10. package/electron/main.js +19 -5
  11. package/electron/offer-session.js +51 -0
  12. package/electron/setup-ui.js +46 -39
  13. package/electron/workspace-ui/live-dead-seats.js +163 -91
  14. package/electron/workspace-ui/workspace-banners.js +30 -7
  15. package/electron/workspace-ui/workspace-matrix.js +23 -3
  16. package/electron/workspace-ui/workspace-seats.js +95 -79
  17. package/package.json +1 -1
  18. package/schemas/council-run.schema.json +2 -2
  19. package/schemas/council-tally.schema.json +17 -1
  20. package/schemas/council-verdict.schema.json +12 -4
  21. package/schemas/run.schema.json +6 -1
  22. package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
  23. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  24. package/skills/second-opinion/MODEL-NOTES.md +88 -9
  25. package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
  26. package/skills/second-opinion/SKILL.md +151 -36
  27. package/src/cli-council-run-bench.js +98 -6
  28. package/src/cli-handlers-council-run.js +18 -6
  29. package/src/cli-handlers-council.js +57 -7
  30. package/src/cli-handlers-doctor.js +1 -1
  31. package/src/cli.js +3 -1
  32. package/src/council/anonymize.js +2 -1
  33. package/src/council/briefings-chair-task.js +161 -0
  34. package/src/council/briefings-chair.js +33 -8
  35. package/src/council/briefings-debate.js +79 -13
  36. package/src/council/briefings-stage2-task.js +236 -0
  37. package/src/council/briefings-stage2.js +103 -26
  38. package/src/council/briefings-task.js +167 -0
  39. package/src/council/briefings.js +41 -4
  40. package/src/council/chair-fallback.js +95 -0
  41. package/src/council/debate.js +38 -21
  42. package/src/council/findings.js +3 -2
  43. package/src/council/ledger.js +2 -2
  44. package/src/council/parse-stage2.js +63 -15
  45. package/src/council/report-cost.js +61 -0
  46. package/src/council/report-html.js +26 -4
  47. package/src/council/report-md.js +30 -2
  48. package/src/council/report.js +40 -37
  49. package/src/council/run-assemble.js +21 -6
  50. package/src/council/run-chair.js +44 -95
  51. package/src/council/run-debate-revote.js +81 -49
  52. package/src/council/run-debate.js +51 -34
  53. package/src/council/run-finish.js +5 -3
  54. package/src/council/run-retry-keys.js +4 -4
  55. package/src/council/run-retry-launch.js +4 -4
  56. package/src/council/run-retry-notes.js +72 -15
  57. package/src/council/run-stage1-launch.js +4 -4
  58. package/src/council/run-stage1-rows.js +9 -6
  59. package/src/council/run-stage2.js +81 -47
  60. package/src/council/run-stages.js +9 -21
  61. package/src/council/run-stats-entry.js +46 -1
  62. package/src/council/run.js +28 -13
  63. package/src/council/seats.js +2 -2
  64. package/src/council/stage1-bind.js +3 -2
  65. package/src/council/verdict-seat-loss.js +124 -0
  66. package/src/council/verdict.js +108 -99
  67. package/src/headless.js +256 -49
  68. package/src/mcp-council-bench.js +64 -3
  69. package/src/mcp-council-run.js +10 -3
  70. package/src/mcp-server.js +52 -12
  71. package/src/mcp-tools.js +41 -5
  72. package/src/observe/council-legs.js +2 -2
  73. package/src/opencode-client.js +19 -1
  74. package/src/pack/pack-forward.js +15 -12
  75. package/src/pack/pack-resolve.js +1 -1
  76. package/src/prompt-builder.js +17 -1
  77. package/src/sidecar/fanout-leg-fallback.js +2 -1
  78. package/src/sidecar/fanout-leg.js +26 -0
  79. package/src/sidecar/fanout.js +1 -1
  80. package/src/sidecar/list-council.js +178 -0
  81. package/src/sidecar/list-limit.js +3 -1
  82. package/src/sidecar/list-search.js +2 -1
  83. package/src/sidecar/models-render.js +71 -0
  84. package/src/sidecar/models.js +19 -45
  85. package/src/sidecar/read.js +34 -10
  86. package/src/sidecar/reopen-spend.js +2 -1
  87. package/src/sidecar/setup.js +13 -4
  88. package/src/sidecar/start.js +2 -1
  89. package/src/template/render.js +16 -7
  90. package/src/utils/alias-audit.js +10 -3
  91. package/src/utils/alias-shadow-writer.js +220 -0
  92. package/src/utils/alias-shadow.js +294 -0
  93. package/src/utils/config.js +1 -1
  94. package/src/utils/curated-models.js +19 -9
  95. package/src/utils/degrade.js +12 -5
  96. package/src/utils/doctor-alias-check.js +2 -5
  97. package/src/utils/engine-log-parse.js +289 -0
  98. package/src/utils/engine-log-tail.js +114 -0
  99. package/src/utils/engine-log.js +250 -0
  100. package/src/utils/engine-skew-records.js +146 -0
  101. package/src/utils/engine-skew.js +300 -0
  102. package/src/utils/gateway-router.js +21 -3
  103. package/src/utils/model-canonicalization.js +55 -6
  104. package/src/utils/model-catalog.js +27 -9
  105. package/src/utils/model-fetcher.js +69 -16
  106. package/src/utils/model-shortlist.js +5 -2
  107. package/src/utils/provider-default-picker.js +6 -3
  108. package/src/utils/quick-picks.js +45 -7
  109. package/src/utils/result-schema.js +17 -1
  110. package/src/utils/text-sanitize.js +81 -0
  111. package/src/utils/ttft.js +57 -0
  112. package/src/utils/untrusted-fence.js +111 -1
  113. package/src/workspace/fold-format.js +28 -7
  114. package/src/workspace/live-normalize.js +2 -1
  115. package/src/workspace/matrix-model.js +6 -2
  116. package/src/workspace/run-detail.js +33 -7
  117. package/src/workspace/seat-space.js +10 -6
package/src/headless.js CHANGED
@@ -21,6 +21,13 @@ const { buildFoldMarker, trailingFoldMarkerRegex, generateFoldNonce } = require(
21
21
  // that uses this helper. See src/utils/env-num.js for why the older `||` knobs are
22
22
  // deliberately left alone.
23
23
  const { envNumber } = require('./utils/env-num');
24
+ // v4.9 W10 (#133 piece 2): the engine's own error line for a dead session.
25
+ const { engineErrorForSession } = require('./utils/engine-log');
26
+ // v4.9 W10 (#133 piece 3): the standing engine version-skew record, if any.
27
+ const { currentEngineSkew, formatSkewSuffix } = require('./utils/engine-skew');
28
+ // v4.9 W13 Task A (PR #207 round 3, B3): the one honesty predicate every ttftMs
29
+ // emit gate shares — see src/utils/ttft.js for why `typeof` was not it.
30
+ const { isMeasuredTtft } = require('./utils/ttft');
24
31
 
25
32
  /**
26
33
  * Fold marker that the agent outputs when done.
@@ -158,14 +165,26 @@ function withTimeout(promise, ms, label) {
158
165
  * Task 6 (#129, #133): build the NO_OUTPUT_BACKSTOP reason string. Report
159
166
  * ONLY what the mechanism observed — a deadline passed with no substantive
160
167
  * activity (output/reasoning/tool calls) — never a cause. At the pre-send
161
- * firing site (runHeadless, ~:506-518) the backstop can win the race against
168
+ * firing site (runHeadless, where the backstop is armed just before
169
+ * `sendPromptAsync`) the backstop can win the race against
162
170
  * sendPromptAsync before the send ever resolves, so "the endpoint accepted
163
171
  * the request" is not even something that site observed. The previous text
164
172
  * asserted "likely a listed-but-not-serving model or a dead endpoint" — a
165
173
  * canned guess with no evidence gate — which sent 30 minutes of #133's
166
174
  * debugging at model ids and API keys while the real cause (an opencode
167
- * engine version skew) sat in ~/.local/share/opencode/log/opencode.log the
168
- * whole time.
175
+ * engine version skew) sat in the engine's own log the whole time.
176
+ *
177
+ * v4.9 W10 (#133 piece 2): that log is now READ. When
178
+ * src/utils/engine-log.js finds an ERROR line for this leg's session,
179
+ * `engineLogExcerpt` carries it and ` — engine log: <excerpt>` is appended
180
+ * AFTER the whole sentence below — so the `NO_OUTPUT_BACKSTOP:` prefix that
181
+ * src/sidecar/models-probe.js classifies on stays byte-stable, and on every
182
+ * miss path (no dir, no file, no session match, no ERROR line) the excerpt
183
+ * clause is absent — a standing skew still appends its own clause, see below. NOTE the path this comment used to
184
+ * name (a single `opencode/log/opencode.log`) was already stale when it was
185
+ * written: the engine now writes one timestamped file per process, and both
186
+ * schemes are live on real machines — engine-log.js's header records the
187
+ * measurement and reads both.
169
188
  *
170
189
  * `fromEnv` distinguishes two ways `ms` was decided, NOT whether
171
190
  * AMICUS_NO_OUTPUT_BACKSTOP_MS is relevant — it is relevant on both branches:
@@ -190,20 +209,54 @@ function withTimeout(promise, ms, label) {
190
209
  * it governs (false on the probe) or omitting it (false/unhelpful on the
191
210
  * retry) — true on both, and still points a user at the remedy.
192
211
  *
212
+ * v4.9 W10 (#133 piece 3): `engineSkew` adds a SECOND, independent clause —
213
+ * the two engine versions, when src/utils/engine-skew.js has a skew standing
214
+ * for THIS leg's own server (the caller reads the record by client; see the
215
+ * firing-site comment in runHeadless). It is
216
+ * deliberately NOT gated on `engineLogExcerpt`: gating it would make the
217
+ * reliable signal (two version strings both sides already publish) depend on
218
+ * the unreliable one (a log file that may be absent or rotated), and in #133
219
+ * the skew WAS the answer. Both clauses are append-only, so with neither the
220
+ * string is byte-for-byte what it was before either piece existed.
221
+ *
193
222
  * Kept module-scope and pure (not a closure over runHeadless locals) so it
194
223
  * can be asserted on directly in tests without driving the poll loop; the
195
224
  * `noOutputBackstopReason` closure inside runHeadless just forwards to this
196
- * with the per-run `noOutputBackstopMs`/`backstopFromEnv` values, so the two
197
- * firing sites there stay identical to what's tested here.
198
- * @param {{ms: number, fromEnv: boolean}} args
225
+ * with the per-run `noOutputBackstopMs`/`backstopFromEnv`/engine-log/skew
226
+ * values, so the two firing sites there stay identical to what's tested here.
227
+ * @param {{ms: number, fromEnv: boolean, engineLogExcerpt?: string|null,
228
+ * engineSkew?: {server: string, installed: string}|null}} args
199
229
  * @returns {string}
200
230
  */
201
- function formatNoOutputBackstopReason({ ms, fromEnv }) {
202
- return 'NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in '
231
+ function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSkew }) {
232
+ const observed = 'NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in '
203
233
  + `${Math.round(ms / 1000)}s — `
204
234
  + (fromEnv
205
235
  ? 'the AMICUS_NO_OUTPUT_BACKSTOP_MS window (0 disables)'
206
236
  : 'a caller-set window overriding the AMICUS_NO_OUTPUT_BACKSTOP_MS default');
237
+ // Append-only: absent/empty excerpt ⇒ the string above, unchanged byte for byte.
238
+ const quoted = engineLogExcerpt ? `${observed} — engine log: ${engineLogExcerpt}` : observed;
239
+ // Append-only for the same reason: no skew ⇒ formatSkewSuffix returns ''.
240
+ return `${quoted}${formatSkewSuffix(engineSkew)}`;
241
+ }
242
+
243
+ /**
244
+ * v4.9 W10 (#133 piece 2): the engine-log lookup, wrapped so it can never
245
+ * become the failure it reports on. The resolver is already best-effort
246
+ * internally; this second belt is at the CALL site because the caller is a
247
+ * leg's death report — the one place where an extra exception would replace a
248
+ * usable failure reason with a stack trace. Missing sessionId ⇒ skip entirely.
249
+ * @param {string|undefined} sessionId
250
+ * @param {object|undefined} engineLogOptions - test seam (options._engineLog)
251
+ * @returns {string|null}
252
+ */
253
+ function engineErrorExcerptSafe(sessionId, engineLogOptions) {
254
+ if (!sessionId) { return null; }
255
+ try {
256
+ return engineErrorForSession(sessionId, engineLogOptions || {});
257
+ } catch (_e) {
258
+ return null;
259
+ }
207
260
  }
208
261
 
209
262
  /**
@@ -366,6 +419,29 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
366
419
  const { IdleWatchdog } = require('./utils/idle-watchdog');
367
420
  let watchdog;
368
421
  let uninstallSignals;
422
+ // v4.9 W13 Task A — THE TTFT PROBE's storage. Milliseconds from the leg
423
+ // asking for output (`outputClockStartedAt` below — the backstop's OWN clock
424
+ // origin) to the first poll where `substantiveActivity` is true. `null` until
425
+ // then, and EMIT-WHEN-SET from there: a leg that never produced anything
426
+ // carries no ttftMs key on the result, on disk, or in any downstream
427
+ // document. `0` is a real measurement (first substantive tick inside the
428
+ // first poll), which is why the absence is a missing key rather than a falsy
429
+ // value.
430
+ //
431
+ // ⚠️ Declared HERE, beside `sessionId`/`watchdog`, and NOT inside the try
432
+ // below (PR #203 council round 1, finding A2). It used to live in the poll
433
+ // loop's scope, so it was not even in scope at the catch-all return: a leg
434
+ // that streamed real output and then threw discarded its own measurement.
435
+ // Same shape as `sessionId`, for the same reason — the outer handler is a
436
+ // return path like any other and must be able to report what was measured.
437
+ //
438
+ // ⚠️ PROBE ONLY — nothing derives from this number. No backstop change, no
439
+ // threshold, no per-model window. W13 ruling R12 is explicitly "probe first,
440
+ // derive later": the C2 derivation (per-model backstops from evidence) waits
441
+ // for real field observations, which cannot exist until this ships. Do not
442
+ // wire it into a decision without that evidence — and read the RESIDUAL
443
+ // CENSORING note on the stamp site in the poll loop before you do.
444
+ let ttftMs = null;
369
445
 
370
446
  try {
371
447
  if (!externalServer) {
@@ -535,15 +611,41 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
535
611
  const backstopFromEnv = !Number.isFinite(options.noOutputBackstopMs);
536
612
  const noOutputBackstopMs = backstopFromEnv
537
613
  ? resolveNoOutputBackstopMs(options._env) : options.noOutputBackstopMs;
538
- const noOutputBackstop = createNoOutputBackstop({ ms: noOutputBackstopMs, startedAt: Date.now() });
614
+ // v4.9 W13 Task A: ONE clock origin, captured once and shared with the
615
+ // backstop instead of a second `Date.now()`. `startedAt` here already means
616
+ // "time since the leg asked for output" (see the block comment above), so
617
+ // the TTFT probe below measures from exactly the instant the backstop
618
+ // starts counting — the two can never disagree about when the wait began.
619
+ const outputClockStartedAt = Date.now();
620
+ const noOutputBackstop = createNoOutputBackstop({ ms: noOutputBackstopMs, startedAt: outputClockStartedAt });
539
621
  let backstopFired = false;
540
622
  // Single source for the reason string so the pre-send firing site below and
541
623
  // the per-poll firing site further down (still ticking the SAME instance)
542
624
  // can never drift apart. Forwards to the module-scope, pure
543
625
  // formatNoOutputBackstopReason (below/exported) so tests can assert on the
544
626
  // string shape directly without driving the whole poll loop.
627
+ //
628
+ // v4.9 W10 (#133 piece 2): the engine-log excerpt is resolved HERE, in the
629
+ // one shared closure, rather than separately at each firing site — the
630
+ // enrichment then reaches BOTH sites by construction, which is the same
631
+ // no-drift argument that put the reason string here in the first place.
632
+ // Resolved at CALL time (not once when this closure is defined) so the
633
+ // engine has already written the line by the time we read it, and so the
634
+ // read never happens on a leg that lives.
635
+ //
636
+ // v4.9 W10 (#133 piece 3): the skew record is read at call time for the
637
+ // same reason — `createSession` above is what puts it there, and on the
638
+ // shared-server path a sibling leg on THIS server may have been the one to
639
+ // see it. Asked for by `client`, so what comes back is THIS leg's own
640
+ // server's standing record and never a stranger's (W10 round-1 review A3):
641
+ // the record is per server and refreshed on every create, so a skew that
642
+ // was fixed mid-run, or one that belongs to another server this process
643
+ // also talks to, cannot ride out on this death report. The read is a Map
644
+ // lookup and does no I/O, so unlike the log read it needs no guard.
545
645
  const noOutputBackstopReason = () => formatNoOutputBackstopReason({
546
646
  ms: noOutputBackstopMs, fromEnv: backstopFromEnv,
647
+ engineLogExcerpt: engineErrorExcerptSafe(sessionId, options._engineLog),
648
+ engineSkew: currentEngineSkew(client),
547
649
  });
548
650
 
549
651
  // Send prompt asynchronously (returns immediately, we poll for results) —
@@ -638,6 +740,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
638
740
  let lastMessageCount = 0;
639
741
  let lastReasoningLength = 0; // B53: track reasoning-output growth to detect thinking
640
742
  let lastProgressAt = Date.now(); // B53: last poll where `progressed` was true
743
+ // v4.9 W13 Task A: `ttftMs` itself is declared at function scope (see the
744
+ // block comment beside `sessionId`) so the catch-all return can carry it.
641
745
  let toolStalled = false; // B53: distinct from completed/timedOut/aborted — see resolveTerminalState
642
746
  let lastSettledToolCount = 0; // B4: tool calls observed reaching a terminal status
643
747
 
@@ -774,6 +878,133 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
774
878
  elapsed: Date.now() - startTime
775
879
  });
776
880
 
881
+ // ---- per-poll activity, read ONCE, ABOVE every completion gate --------
882
+ // Activity-aware idle detection: ANY of text growth, a new tool call, a new
883
+ // tool result, a new message, a new assistant message id, or reasoning-output
884
+ // growth counts as progress. Only count toward completion when NOTHING changed
885
+ // (genuine idle).
886
+ //
887
+ // ⚠️ POSITION IS LOAD-BEARING (v4.9 W13, PR #203 council round 1, findings
888
+ // A1+B1). This block used to sit BELOW the four completion gates that
889
+ // follow — fold-marker, boundary-provider-error, error-with-no-output and
890
+ // sdk-idle — every one of which `break`s. A leg whose first substantive
891
+ // output and whose completion landed in the SAME poll therefore never
892
+ // reached the TTFT stamp, and those are exactly the FAST legs, so the
893
+ // censoring truncated the left tail rather than sampling at random.
894
+ // Hoisting is value-preserving, and the AUDIT that says so is below rather
895
+ // than an appeal to construction (PR #207 round 4, B2 — the earlier wording
896
+ // here claimed "every `last*` tracker is written only here" and "nothing
897
+ // reads them after the loop", and BOTH are false as written, because
898
+ // `lastAssistantMsgId` is a `last*` tracker this block never writes).
899
+ // MEASURED, every reader in this file:
900
+ // · `mirror` changes only at the `mirrorMessages` call above, so every
901
+ // delta below reads the same numbers it read at the old site.
902
+ // · This block WRITES exactly seven trackers — output length, tool-call
903
+ // count, tool-result count, message count, reasoning length, settled-
904
+ // tool count and `lastProgressAt`. Outside this block those seven have
905
+ // exactly ONE reader in the whole file: `lastProgressAt`, in the B53
906
+ // tool-stall gate further down. That gate sat below the block's OLD
907
+ // position too, so their order is unchanged. None of the seven is read
908
+ // after the loop.
909
+ // · `lastAssistantMsgId` is READ here (`newAssistant`) and written where
910
+ // it always was, at the BOTTOM of the loop below every gate — so the
911
+ // comparison still runs against the previous poll's id, and the one
912
+ // post-loop `last*` reader (`hasAssistantMsg` on the timeout result)
913
+ // reads a tracker this block does not touch.
914
+ // The only behaviour change is that a poll which BREAKS now advances those
915
+ // seven first, and nothing observes them afterwards.
916
+ // Hoisting the whole block (rather than adding a second stamp beside each
917
+ // gate) is what keeps ONE definition of the predicate and ONE stamp site.
918
+ const outputGrew = mirror.output.length > lastOutputLength;
919
+ lastOutputLength = mirror.output.length;
920
+ const toolActivity = mirror.toolCalls.length > lastToolCallCount;
921
+ lastToolCallCount = mirror.toolCalls.length;
922
+ const resultActivity = mirror.seenToolResultIds.size > lastToolResultCount;
923
+ lastToolResultCount = mirror.seenToolResultIds.size;
924
+ const messageActivity = messageCount > lastMessageCount;
925
+ lastMessageCount = messageCount;
926
+ const newAssistant = currentAssistantMsgId !== lastAssistantMsgId;
927
+ // B53: an interleaved-thinking model with a pending tool call can stream ONLY
928
+ // reasoning deltas for minutes with no text/tool/result/message growth — mirror
929
+ // the F6d treatment in conversation-mirror.js (reasoning growth = activity) so
930
+ // the stall clock resets instead of falsely firing "Tool call stalled".
931
+ const reasoningActivity = mirror.reasoningOutput.length > lastReasoningLength;
932
+ lastReasoningLength = mirror.reasoningOutput.length;
933
+ // v4.4 B4: a tool call REACHING a terminal status is real activity. Before
934
+ // the shape fix this could never be observed (pending never cleared), so a
935
+ // multi-tool leg's stall clock only reset on text growth.
936
+ const settleActivity = mirror.settledToolCallIds.size > lastSettledToolCount;
937
+ lastSettledToolCount = mirror.settledToolCallIds.size;
938
+
939
+ const progressed = outputGrew || toolActivity || resultActivity || messageActivity
940
+ || newAssistant || reasoningActivity || settleActivity;
941
+ if (progressed) { lastProgressAt = Date.now(); }
942
+
943
+ // v4.6.2 PR2 amendment 2 (controller live smoke + debug trace): the
944
+ // backstop disarms only on SUBSTANTIVE activity — output, reasoning,
945
+ // or tool motion (the spec's "first token/reasoning/tool_use").
946
+ // messageActivity/newAssistant are excluded: OpenCode creates an empty
947
+ // assistant placeholder on prompt ACCEPTANCE, which is precisely the
948
+ // accepted-but-not-serving bookkeeping the backstop must not trust.
949
+ // `progressed` itself (and every stall/idle consumer of it above) is
950
+ // deliberately untouched — this is the narrower signal.
951
+ //
952
+ // v4.9 W13 Task A: it now has TWO readers — the TTFT probe on the next
953
+ // line and the backstop tick further down — which is why it stopped
954
+ // being described as "backstop-only". They read the SAME const on
955
+ // purpose: "the time to first token" and "the moment the backstop
956
+ // disarms" are the same instant BY CONSTRUCTION, and a second definition
957
+ // of the predicate is exactly how those two would silently drift apart.
958
+ const substantiveActivity = outputGrew || toolActivity || resultActivity
959
+ || reasoningActivity || settleActivity;
960
+
961
+ // v4.9 W13 Task A — THE TTFT STAMP. One shot, never re-armed, reading
962
+ // the same `substantiveActivity` const the backstop is ticked with
963
+ // below. Keyed on `progressed` it would report a first-token time for
964
+ // OpenCode's empty acceptance placeholder — the exact lie the
965
+ // amendment-2 narrowing of `substantiveActivity` removed from the backstop.
966
+ //
967
+ // ⚠️ RESIDUAL CENSORING — READ THIS BEFORE DERIVING ANYTHING (the C2
968
+ // derivation must know the bias; PR #203 round 1, B1). With the hoist
969
+ // above, every leg whose activity is OBSERVED by a poll is measured.
970
+ // What is still unmeasurable, and therefore what an ABSENT ttftMs can
971
+ // mean besides "served nothing":
972
+ // 1. Externally aborted legs. The metadata `status: 'aborted'` check
973
+ // runs at the TOP of the poll body, before `getMessages`, and
974
+ // breaks — so anything served since the previous poll is never
975
+ // mirrored.
976
+ // 2. Poll-failure bail. `getMessages` failing
977
+ // `maxConsecutivePollFailures` times in a row ends the leg with
978
+ // whatever the last SUCCESSFUL poll saw.
979
+ // 3. `--timeout` expiry between polls: the while-condition ends the
980
+ // loop, so output that arrived during the final sleep is unseen.
981
+ // 4. The pre-send backstop path, which skips the poll loop entirely —
982
+ // a model that began streaming while `sendPromptAsync` hung is
983
+ // never polled at all.
984
+ // 5. A backward wall-clock jump before the first substantive poll
985
+ // (PR #207 round 3, B3). The reading is taken but is not a
986
+ // measurement, so the emit gates drop it — see the ruling below.
987
+ // And the value itself is QUANTIZED UPWARD: it is stamped at poll time,
988
+ // not at token time, so every measurement is an upper bound carrying up
989
+ // to one `pollIntervalMs` (plus the getMessages round-trip) of slack.
990
+ // Nothing extra is recorded for the censored cases on purpose — there is
991
+ // no honest number to record, and a sentinel would be read as data.
992
+ //
993
+ // ⚠️ CLOCK-SKEW RULING (PR #207 round 3, B3). This is a wall-clock
994
+ // delta, not a monotonic one, so a backward jump between
995
+ // `outputClockStartedAt` and this poll — NTP correction, a VM resuming
996
+ // from suspend, a manual clock set — measures NEGATIVE. Such a reading
997
+ // is DROPPED at the emit gates (`isMeasuredTtft`), never clamped:
998
+ // clamping to `0` would publish "first token inside the first poll",
999
+ // the most consequential value in the distribution the C2 derivation
1000
+ // will read, for a leg that measured nothing of the kind.
1001
+ //
1002
+ // The stamp stays one-shot and unguarded ON PURPOSE. Re-arming after a
1003
+ // skewed reading would let a LATER poll stamp a delta against the same
1004
+ // displaced origin: a bigger number, equally wrong, and no longer even
1005
+ // the first token. One decision point (the gate) beats two.
1006
+ if (ttftMs === null && substantiveActivity) { ttftMs = Date.now() - outputClockStartedAt; }
1007
+
777
1008
  // Check for the completion marker as the FINAL non-empty line, carrying
778
1009
  // THIS run's nonce (#BL-7 + 15b.3). Models may emit a bare or wrong-nonce
779
1010
  // marker on its own line mid-output (echoing a prior sidecar, these
@@ -830,46 +1061,6 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
830
1061
  }
831
1062
  }
832
1063
 
833
- // Activity-aware idle detection: ANY of text growth, a new tool call, a new
834
- // tool result, a new message, a new assistant message id, or reasoning-output
835
- // growth counts as progress. Only count toward completion when NOTHING changed
836
- // (genuine idle).
837
- const outputGrew = mirror.output.length > lastOutputLength;
838
- lastOutputLength = mirror.output.length;
839
- const toolActivity = mirror.toolCalls.length > lastToolCallCount;
840
- lastToolCallCount = mirror.toolCalls.length;
841
- const resultActivity = mirror.seenToolResultIds.size > lastToolResultCount;
842
- lastToolResultCount = mirror.seenToolResultIds.size;
843
- const messageActivity = messageCount > lastMessageCount;
844
- lastMessageCount = messageCount;
845
- const newAssistant = currentAssistantMsgId !== lastAssistantMsgId;
846
- // B53: an interleaved-thinking model with a pending tool call can stream ONLY
847
- // reasoning deltas for minutes with no text/tool/result/message growth — mirror
848
- // the F6d treatment in conversation-mirror.js (reasoning growth = activity) so
849
- // the stall clock resets instead of falsely firing "Tool call stalled".
850
- const reasoningActivity = mirror.reasoningOutput.length > lastReasoningLength;
851
- lastReasoningLength = mirror.reasoningOutput.length;
852
- // v4.4 B4: a tool call REACHING a terminal status is real activity. Before
853
- // the shape fix this could never be observed (pending never cleared), so a
854
- // multi-tool leg's stall clock only reset on text growth.
855
- const settleActivity = mirror.settledToolCallIds.size > lastSettledToolCount;
856
- lastSettledToolCount = mirror.settledToolCallIds.size;
857
-
858
- const progressed = outputGrew || toolActivity || resultActivity || messageActivity
859
- || newAssistant || reasoningActivity || settleActivity;
860
- if (progressed) { lastProgressAt = Date.now(); }
861
-
862
- // v4.6.2 PR2 amendment 2 (controller live smoke + debug trace): the
863
- // backstop disarms only on SUBSTANTIVE activity — output, reasoning,
864
- // or tool motion (the spec's "first token/reasoning/tool_use").
865
- // messageActivity/newAssistant are excluded: OpenCode creates an empty
866
- // assistant placeholder on prompt ACCEPTANCE, which is precisely the
867
- // accepted-but-not-serving bookkeeping the backstop must not trust.
868
- // `progressed` itself (and every stall/idle consumer of it above) is
869
- // deliberately untouched — this is a narrower, backstop-only signal.
870
- const substantiveActivity = outputGrew || toolActivity || resultActivity
871
- || reasoningActivity || settleActivity;
872
-
873
1064
  // No-output backstop: one tick per poll. Fired is terminal — break the
874
1065
  // loop; the post-loop block below mirrors the timeout path.
875
1066
  if (noOutputBackstop.tick(substantiveActivity, Date.now()) === 'fired') {
@@ -1309,6 +1500,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1309
1500
  // #133 P1: sessionId was assigned at :413/:417, well before this
1310
1501
  // return — guaranteed set here, same as `taskId` above.
1311
1502
  opencodeSessionId: sessionId,
1503
+ // v4.9 W13 Task A: emit-when-set. This return is reached by legs that
1504
+ // failed with no USABLE output, which is not the same as no output at
1505
+ // all — a leg that streamed reasoning and then errored has a real ttft
1506
+ // and must keep it. (PR #207 round 3, B3: emit-when-VALID too — see the
1507
+ // clock-skew ruling at the stamp site above.)
1508
+ ...(isMeasuredTtft(ttftMs) ? { ttftMs } : {}),
1312
1509
  error: sessionError
1313
1510
  };
1314
1511
  }
@@ -1326,6 +1523,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1326
1523
  ...subtreeResult,
1327
1524
  // #133 P1: see the comment on the sibling return above — guaranteed set.
1328
1525
  opencodeSessionId: sessionId,
1526
+ // v4.9 W13 Task A: emit-when-set — see the sibling return above.
1527
+ ...(isMeasuredTtft(ttftMs) ? { ttftMs } : {}),
1329
1528
  exitCode: 0
1330
1529
  };
1331
1530
 
@@ -1410,6 +1609,14 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1410
1609
  // outcomes explicit and already schema-shaped (run.schema.json:23
1411
1610
  // wants string|null, never undefined).
1412
1611
  opencodeSessionId: sessionId || null,
1612
+ // v4.9 W13 Task A (PR #203 round 1, A2): emit-when-set, the SAME rule as
1613
+ // the two returns in the try body. An exception is not evidence that
1614
+ // nothing was served — a leg can stream a first token and explode in the
1615
+ // post-loop finalization (`server.close()` is the one unguarded await on
1616
+ // that path), and throwing away a measurement it already made would be a
1617
+ // second, silent loss on top of the first. Nothing is invented: a leg
1618
+ // that exploded before any poll observed activity still carries no key.
1619
+ ...(isMeasuredTtft(ttftMs) ? { ttftMs } : {}),
1413
1620
  error: error.message
1414
1621
  };
1415
1622
  }
@@ -3,8 +3,10 @@
3
3
 
4
4
  /**
5
5
  * @module mcp-council-bench
6
- * Bench resolution for `amicus_council_run` (models XOR council preset). Split
7
- * out of mcp-council-run.js (v4.6 Plan 4 Task 4b): that file sat at 298/300
6
+ * Bench resolution for `amicus_council_run` (models XOR council preset), plus
7
+ * the MCP transport's alias-shadow notice site (`auditBenchAliases`, PR #207
8
+ * round 2 A1 — the mirror of what cli-council-run-bench.js owns for the CLI).
9
+ * Split out of mcp-council-run.js (v4.6 Plan 4 Task 4b): that file sat at 298/300
8
10
  * lines and the --dropped-members producer (the MCP→child transport-parity
9
11
  * fix) needed the room. `resolveBenchInput` is self-contained — no dependency
10
12
  * on the handler's validation/spawn-argv logic — so it moves verbatim to its
@@ -46,4 +48,63 @@ function resolveBenchInput(input) {
46
48
  return { bench: inputModels, presetName: null, droppedMembers: [] };
47
49
  }
48
50
 
49
- module.exports = { resolveBenchInput };
51
+ /**
52
+ * THE MCP-SIDE alias-shadow notice site (PR #207 council round 2, finding A1).
53
+ *
54
+ * The CLI's twin lives in `cli-council-run-bench.js :: resolveBench`; each
55
+ * transport's bench module owns its own audit site, and both call the shared
56
+ * `auditAliasShadows` entry point and nothing else.
57
+ *
58
+ * ⚠️ WHY A SECOND SITE AT ALL. Round 1 (finding A4) measured that the CLI seam
59
+ * EXECUTES on the MCP path — `mcp-council-run.js` always spawns the child with
60
+ * an expanded `--models` — but SURFACES nothing there: `spawnSidecarProcess`
61
+ * gives the child `stdio: ['ignore','ignore',<fd>]` on `<runDir>/debug.log`, so
62
+ * its stderr is a file the client never reads. This site writes into the tool
63
+ * result instead, so the MCP caller actually sees it. The two copies live on
64
+ * different surfaces (a tool-result block here; `debug.log` there), so neither
65
+ * surface ever double-prints.
66
+ *
67
+ * Called from the HANDLER rather than from `resolveBenchInput` above, because
68
+ * only the handler has the chair and the critic — and because the handler runs
69
+ * on every council_run call while a wiring inside `resolveBenchInput` would sit
70
+ * on both of its branches but still miss nothing else. Diagnosis only: this
71
+ * changes no id, no exit code and no artifact.
72
+ *
73
+ * ⚠️ WHERE IN THE HANDLER, and why it moved (PR #207 round 5, A1). The call used
74
+ * to sit beside the bench validation, ABOVE the critic+lenses mutual exclusion,
75
+ * the lens-count mismatch and every later rejection. `notices` is only ever read
76
+ * at the very end, when the tool result is assembled, so each of those
77
+ * rejections computed an audit — reading the user's `config.json` off disk,
78
+ * comparing two alias tables, formatting lines — and then discarded every line
79
+ * with the rest of the result. Dead work, and it contradicted the CLI's "a
80
+ * rejected bench is silent by construction" as well as the suite's own
81
+ * rejected-run control (which passed only because it exercised the ONE rejection
82
+ * that returns before the audit).
83
+ *
84
+ * So the site is now immediately after the spawn succeeds — the first point from
85
+ * which the handler is guaranteed to return the success content. Everything left
86
+ * below it is best-effort (the pid write, the notify request, auto-open), and
87
+ * none of it can return early. "Never computed on a rejected call" is therefore
88
+ * structural rather than a property of which rejections happen to sit above it,
89
+ * which is what the previous placement got wrong.
90
+ *
91
+ * ⚠️ AND THE LINE IS RE-SHAPED FOR THIS SURFACE (PR #207 round 5, D3).
92
+ * `formatAliasShadow` terminates every notice with `\n`, because both of its
93
+ * stream writers need it to make the text a line. An MCP content block is not a
94
+ * stream: its sibling notices (`pack-resolve.js`) carry no trailing newline, so
95
+ * an un-trimmed alias-shadow block rendered with a stray blank line after it.
96
+ * The trim lives HERE, at the one writer whose sink is not a stream, rather than
97
+ * in `formatAliasShadow` — the newline is correct for the CLI and `models
98
+ * --check`, and the round-4 sanitizing pins assert `endsWith('\n')` on that side.
99
+ * @param {string[]} bench resolved bench seats (already expanded)
100
+ * @param {string} chair explicit or default chair alias
101
+ * @param {string|null} critic critic alias, when one was named
102
+ * @param {string[]} notices the handler's per-call notice array (tool-result blocks)
103
+ */
104
+ function auditBenchAliases(bench, chair, critic, notices) {
105
+ require('./utils/alias-shadow').auditAliasShadows(
106
+ [...bench, chair, ...(critic ? [critic] : [])],
107
+ (line) => notices.push(String(line).trimEnd()));
108
+ }
109
+
110
+ module.exports = { resolveBenchInput, auditBenchAliases };
@@ -19,7 +19,7 @@ const { isPathInside } = require('./project-root-allowlist');
19
19
  const { validateOnComplete, requestMcpNotify } = require('./mcp-notify');
20
20
  // v4.6 Plan 4 Task 4b: resolveBenchInput moved to its own leaf (size gate) —
21
21
  // see mcp-council-bench.js's module docblock for why.
22
- const { resolveBenchInput } = require('./mcp-council-bench');
22
+ const { resolveBenchInput, auditBenchAliases } = require('./mcp-council-bench');
23
23
 
24
24
  function textResult(text, isError) {
25
25
  const result = { content: [{ type: 'text', text }] };
@@ -58,7 +58,7 @@ async function handleCouncilRunTool(input, project, helpers) {
58
58
  // for any caller that bypasses schema validation).
59
59
  const oc = validateOnComplete(input.onComplete);
60
60
  if (!oc.ok) { return textResult(oc.error, true); }
61
- const CHAIR_DEFAULT = 'deepseek';
61
+ const { CHAIR_DEFAULT } = require('./cli-council-run-bench'); // v4.9 W13 (PR #203 A6): one owner, not a second copy
62
62
  if (typeof input.briefingFile !== 'string' || !input.briefingFile.trim()) {
63
63
  return textResult("amicus_council_run requires 'briefingFile' (a path to the briefing).", true);
64
64
  }
@@ -204,6 +204,9 @@ async function handleCouncilRunTool(input, project, helpers) {
204
204
  // v4.7 F8 (D13): the spawned CLI child's own cli-handlers-council-run.js
205
205
  // stores the tag on the run.json seed (Task 3) — this handler only forwards.
206
206
  if (input.tag) { args.push('--tag', input.tag); }
207
+ // v4.9 W5.2: emit-when-'task' — 'review' (the zod-declared default spelled
208
+ // out) never reaches the child's argv; review-run argv stays byte-identical.
209
+ if (input.intent === 'task') { args.push('--intent', 'task'); }
207
210
 
208
211
  let child;
209
212
  try { child = helpers.spawnFn(args, runDir); } catch (err) {
@@ -219,6 +222,9 @@ async function handleCouncilRunTool(input, project, helpers) {
219
222
  // read-merge-write has no lock (see run-state.writeSpawnPid).
220
223
  try { if (typeof child?.pid === 'number') { runState.writeSpawnPid(runDir, child.pid); } }
221
224
  catch { /* best-effort */ }
225
+ // PR #207 round 2 (A1) + round 5 (A1): the alias-shadow notice's MCP surface,
226
+ // sited past every return that discards `notices` — rationale on its own def.
227
+ auditBenchAliases(bench, chair, critic, notices);
222
228
  // Task 15 (spec §5.3): the run is now known-launched under runId — mark it
223
229
  // for a best-effort terminal notify. runWait's poll loop (mcp-wait.js) is
224
230
  // the only code that later sees this council run reach terminal state.
@@ -273,7 +279,8 @@ async function handleCouncilRunTool(input, project, helpers) {
273
279
  const content = [{ type: 'text', text: fenceSidecarOutput(body) }];
274
280
  // v4.5 Task 15: pack/template notices (e.g. a bench-override) are non-fatal —
275
281
  // surfaced as extra unfenced content blocks, same precedent as
276
- // mcp-server.js's routeResult.notice (amicus_start).
282
+ // mcp-server.js's routeResult.notice (amicus_start). PR #207 round 2 (A1)
283
+ // added a third producer: the alias-shadow lines from auditBenchAliases.
277
284
  for (const n of notices) { content.push({ type: 'text', text: n }); }
278
285
  return { content };
279
286
  }
package/src/mcp-server.js CHANGED
@@ -390,8 +390,34 @@ const handlers = {
390
390
  // The file itself is written just before the spawn fallback below (the
391
391
  // shared-server path passes the prompt in-process and never reads args).
392
392
  const briefingPath = path.join(sessionDir, 'briefing.md');
393
+ // ⚠️ W1-M4 (v4.9 W12), the amicus_start half of the divergence Task 7 closed
394
+ // for src/mcp-server.js :: amicus_fanout — same shape, same reason.
395
+ // `briefing.md` and the initial `metadata.briefing` below both used to be
396
+ // written from the RAW `input.prompt` while the shared-server branch's own
397
+ // metadata write recorded the rendered text, and for a `start` row
398
+ // `metadata.briefing` IS the `--search` corpus
399
+ // (src/sidecar/list-search.js :: rowMatchesSearch falls through to
400
+ // `row.briefing` for anything that is not a wave or a council run). A child that dies before
401
+ // its own createSessionMetadata therefore left the session permanently
402
+ // unfindable by the text the user actually sees. So: the RENDERED text goes
403
+ // on disk, and the child is handed the RAW input through a sibling
404
+ // `briefing-input.md`, keeping its own later render byte-identical and still
405
+ // the provenance source for `promptMeta.template`.
406
+ //
407
+ // ⚠️ Both defaults are LOAD-BEARING: with no pack-forwarded template nothing
408
+ // renders, so the rendered text IS the raw prompt and childPromptPath must
409
+ // fall back to briefingPath — otherwise every ordinary start would spawn
410
+ // with `--prompt-file undefined`.
411
+ //
412
+ // Hoisted here (it was local to the shared-server branch) so ONE expression
413
+ // serves both paths: two copies of "rendered, else raw" is the drift that
414
+ // produced this defect in the first place.
415
+ const renderedPrompt = fwd.renderedPrompt !== undefined ? fwd.renderedPrompt : input.prompt;
416
+ const childPromptPath = fwd.renderedPrompt !== undefined
417
+ ? path.join(sessionDir, 'briefing-input.md')
418
+ : briefingPath;
393
419
  const detectedClient = detectClient(mcpServer);
394
- const args = ['start', '--prompt-file', briefingPath, '--task-id', taskId, '--client', detectedClient];
420
+ const args = ['start', '--prompt-file', childPromptPath, '--task-id', taskId, '--client', detectedClient];
395
421
  // resolvedModel is always defined here — a routing failure already
396
422
  // returned above — and is the router's executableId, not the raw alias.
397
423
  args.push('--model', resolvedModel);
@@ -434,11 +460,12 @@ const handlers = {
434
460
  let sessionId;
435
461
  try {
436
462
  // v4.5 decision 1 + Wave-1 fix (I1/I2): the template was already
437
- // rendered by the shared prepareForward call above — reuse its text
438
- // instead of rendering again. Notices stay LOCAL (not packNotices):
439
- // a fall-through to the spawn-fallback catch must never leak one.
463
+ // rendered by the shared prepareForward call above — this path reuses
464
+ // its text (`renderedPrompt`, hoisted above with the spawn fallback in
465
+ // W12) instead of rendering again. Notices stay LOCAL (not
466
+ // packNotices): a fall-through to the spawn-fallback catch must never
467
+ // leak one.
440
468
  const inProcessNotices = [...fwd.notices];
441
- const renderedPrompt = fwd.renderedPrompt !== undefined ? fwd.renderedPrompt : input.prompt;
442
469
  // v4.7 PR6: the gate used to hang off `packForward.maxCost !== undefined`,
443
470
  // so a no-pack MCP start skipped it while the CLI (cli-handlers-run.js:90)
444
471
  // gated unconditionally with a cfg.maxCost fallback. Same guard, both doors.
@@ -660,13 +687,18 @@ const handlers = {
660
687
  }
661
688
 
662
689
  // Feature flag disabled (or shared server failed): fall back to per-process spawn.
663
- // BL-1: create the session dir and write the prompt to briefing.md BEFORE the
664
- // spawn so --prompt-file (built above) resolves to a real file, keeping the
665
- // full prompt off the ~32KB-capped Windows command line.
690
+ // BL-1: create the session dir and write the prompt files BEFORE the spawn so
691
+ // --prompt-file (built above) resolves to a real file, keeping the full
692
+ // prompt off the ~32KB-capped Windows command line. W1-M4: briefing.md is
693
+ // the RENDERED text; the sibling briefing-input.md exists only when a pack
694
+ // template actually rendered something (see childPromptPath above).
666
695
  let child;
667
696
  try {
668
697
  fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
669
- fs.writeFileSync(briefingPath, input.prompt, { mode: 0o600 });
698
+ fs.writeFileSync(briefingPath, renderedPrompt, { mode: 0o600 });
699
+ if (childPromptPath !== briefingPath) {
700
+ fs.writeFileSync(childPromptPath, input.prompt, { mode: 0o600 });
701
+ }
670
702
  child = spawnSidecarProcess(args, sessionDir);
671
703
  } catch (err) {
672
704
  return textResult(`Failed to start Amicus: ${err.message}`, true);
@@ -681,8 +713,11 @@ const handlers = {
681
713
  headless: !!input.noUi,
682
714
  // Seed briefing/mode so list/status are informative even before the
683
715
  // CLI child's createSessionMetadata overwrite (or if it crashes first).
716
+ // W1-M4 (see the briefing.md block above): the RENDERED text, twin of
717
+ // the shared-server branch's own write — this field is a start row's
718
+ // `--search` corpus.
684
719
  mode: input.noUi ? 'headless' : 'interactive',
685
- briefing: input.prompt,
720
+ briefing: renderedPrompt,
686
721
  // v4.5 Task 15: additive-only — absent (not null) without a pack.
687
722
  ...(packRecord ? { pack: packRecord } : {}),
688
723
  }, null, 2), { mode: 0o600 });
@@ -1316,7 +1351,8 @@ const handlers = {
1316
1351
  // ⚠️ W1-M4: briefing.md is the SEARCH CORPUS — src/sidecar/list-search.js reads it verbatim
1317
1352
  // — and a child that aborts before fanout.js:145 never re-renders it, leaving the wave
1318
1353
  // permanently unfindable by the text the user actually sees. Write the RENDERED text here
1319
- // (parity with the amicus_start path at :441/:506) and hand the child the raw input in a
1354
+ // (parity with src/mcp-server.js :: amicus_start, whose own `renderedPrompt` was hoisted
1355
+ // above both of its branches in v4.9 W12 — see its W1-M4 block) and hand the child the raw input in a
1320
1356
  // sibling file, so its own later re-render still produces byte-identical output and
1321
1357
  // promptMeta.template provenance survives.
1322
1358
  const briefingText = fwd.renderedPrompt !== undefined ? fwd.renderedPrompt : input.prompt;
@@ -1424,7 +1460,11 @@ const handlers = {
1424
1460
  const record = tally(input);
1425
1461
  // Auto-append to the reliability ledger (parity with `amicus council
1426
1462
  // tally`). Best-effort: a ledger write failure must not fail the tally.
1427
- try { require('./council/ledger').appendRun(record); } catch { /* best-effort */ }
1463
+ // v4.9 W5.4 gate 3: task-run records never feed it — gated on the
1464
+ // RECORD's meta.intent (tally copies meta verbatim from the input).
1465
+ if (!(record.meta && record.meta.intent === 'task')) {
1466
+ try { require('./council/ledger').appendRun(record); } catch { /* best-effort */ }
1467
+ }
1428
1468
  // v4.0 §8 (H9): fence the JSON — council output summarizes untrusted
1429
1469
  // model prose entering the orchestrating agent's context. JSON intact
1430
1470
  // inside the fence; CLI --json stays unfenced (the programmatic channel).