@cruxy/cli 1.7.0 → 1.8.0

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.
@@ -1,4 +1,4 @@
1
- import { ApiError, AuthError, BudgetExhaustedError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
1
+ import { ApiError, AuthError, BudgetExhaustedError, InvalidRequestError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
2
2
  import { scrubModelNames } from "../brand/index.js";
3
3
  import { CruxyError, ErrorCode } from "./types.js";
4
4
  /**
@@ -199,6 +199,91 @@ export function apiError(underlying) {
199
199
  meta: status ? { status } : undefined,
200
200
  });
201
201
  }
202
+ /**
203
+ * The gateway REJECTED what we sent (400/422) — it never reached a model.
204
+ *
205
+ * Everything else `classifyProviderError` produces says, in one wording or
206
+ * another, "wait and try again", because everything else IS a condition that
207
+ * passes. This one is not, and saying so is the entire point. The gateway parsed
208
+ * the request, found it invalid, and answered before any upstream call. The
209
+ * payload is built from this build's own code — the tool harness, the message
210
+ * shaping — so it is deterministic: a retry sends byte-identical content and
211
+ * earns a byte-identical refusal. There is no outage, nothing recovers on a
212
+ * clock, and no amount of patience helps.
213
+ *
214
+ * It follows that this is a DEFECT IN CRUXY and the only thing that fixes it is
215
+ * a code change. So the next steps say that instead of a soothing "retry in a
216
+ * moment", and they point at the issue tracker with a code to quote, because a
217
+ * report is the one action that actually moves this forward.
218
+ *
219
+ * The gateway's message names the offending tool when a tool is at fault (its
220
+ * validator emits `tool "<name>": ...`), and that name is the single most useful
221
+ * token in the whole error — it turns "cruxy is broken" into a filed issue
222
+ * someone can act on. It is lifted out of the SCRUBBED message, never the raw
223
+ * one, so the U.8 gag can never be undone by this path.
224
+ */
225
+ export function apiRequestRejected(underlying) {
226
+ const status = underlying instanceof ApiError ? underlying.status : undefined;
227
+ const cause = scrubbedMessageOf(underlying);
228
+ const tool = toolNamedIn(cause);
229
+ return new CruxyError({
230
+ code: ErrorCode.ApiRequestRejected,
231
+ title: tool
232
+ ? `the provider rejected this request — the \`${tool}\` tool definition cruxy sent is invalid`
233
+ : "the provider rejected this request — cruxy sent something invalid",
234
+ cause,
235
+ nextSteps: [
236
+ // First, because it pre-empts the reflex the other API errors trained.
237
+ "retrying will NOT help: the same request would be sent again and refused identically",
238
+ "this is a defect in cruxy, not a provider outage — nothing recovers on its own",
239
+ tool
240
+ ? `report it at ${ISSUE_URL} with this code and the tool name \`${tool}\``
241
+ : `report it at ${ISSUE_URL} with this code and the cause line above`,
242
+ ],
243
+ underlying,
244
+ meta: {
245
+ ...(status !== undefined ? { status } : {}),
246
+ ...(tool !== undefined ? { tool } : {}),
247
+ },
248
+ });
249
+ }
250
+ /**
251
+ * The tool name in a gateway rejection, if it named one.
252
+ *
253
+ * The gateway's tool-schema validator prefixes its complaint with the offending
254
+ * function — `tool "apply_patch": parameters nests deeper than 8 levels` — so
255
+ * one quoted token after the word `tool` is the whole pattern. Anything else
256
+ * yields `undefined` and the caller falls back to generic wording: a WRONG tool
257
+ * name in a bug report is worse than none, so this never guesses.
258
+ *
259
+ * ── TEMPORARY COUPLING, AND IT IS THE WRONG KIND ────────────────────────────
260
+ *
261
+ * This reads the gateway's `error` MESSAGE, and the gateway's own contract
262
+ * (cruxy-ai/api, `internal/httpx/errcode.go`) says the message MAY change while
263
+ * the `code` MAY NOT. So this parses the half that is explicitly allowed to move
264
+ * under us — the exact coupling the code/message split exists to prevent.
265
+ *
266
+ * It is deliberate and bounded: today the code is the generic `invalid_request`,
267
+ * shared with bad JSON, a missing field and an unknown model, so the message is
268
+ * the ONLY thing distinguishing "this build's tool harness is permanently
269
+ * unusable" from "this one request was malformed". The tool name is the single
270
+ * most actionable token in the error and it is worth having; a regex that fails
271
+ * closed is the cheapest way to have it.
272
+ *
273
+ * Failing closed is what makes the risk acceptable. If the gateway rewords, this
274
+ * returns `undefined`, the caller drops to generic wording, and the error is
275
+ * still correct — less specific, never wrong. Nothing downstream branches on it.
276
+ *
277
+ * The real fix is server-side and filed as cruxy-ai/api#183: a distinct 400 code
278
+ * for harness-bound rejections (the `invalid_schema` precedent already exists
279
+ * for `response_format`), with the tool name as a STRUCTURED FIELD rather than a
280
+ * message prefix. When that lands, match on the code, read the field, and delete
281
+ * this function — do not "improve" the regex.
282
+ */
283
+ function toolNamedIn(message) {
284
+ const match = /\btool "([^"]+)"/.exec(message ?? "");
285
+ return match?.[1];
286
+ }
202
287
  export function apiRateLimit(underlying) {
203
288
  const retryAfterMs = underlying instanceof RateLimitError ? underlying.retryAfterMs : undefined;
204
289
  return new CruxyError({
@@ -1433,6 +1518,14 @@ export function classifyProviderError(underlying) {
1433
1518
  if (underlying instanceof BudgetExhaustedError) {
1434
1519
  return budgetExhausted(underlying);
1435
1520
  }
1521
+ // Also before the `ApiError` base. Without this arm a rejected request lands
1522
+ // on the generic `apiError`, whose only next step is "retry in a moment; if it
1523
+ // persists, check the provider's status" — advice that is wrong twice over for
1524
+ // a 400: retrying re-sends the identical payload, and the provider's status
1525
+ // page has nothing to say about a request it correctly refused.
1526
+ if (underlying instanceof InvalidRequestError) {
1527
+ return apiRequestRejected(underlying);
1528
+ }
1436
1529
  if (underlying instanceof ApiError)
1437
1530
  return apiError(underlying);
1438
1531
  return null;
@@ -44,6 +44,18 @@ export const ErrorCode = {
44
44
  GitPushFailed: "CRUXY_E_GIT_PUSH_FAILED",
45
45
  // api (exit 6)
46
46
  Api: "CRUXY_E_API",
47
+ /**
48
+ * The gateway REJECTED the request (400/422) rather than failing to serve it.
49
+ *
50
+ * A DISTINCT CODE from {@link Api} because the two are opposite kinds of fact
51
+ * and take opposite advice. `Api` covers a provider that could not serve a
52
+ * valid request — a condition, which passes, so "retry in a moment" is sound.
53
+ * This one covers a request the gateway parsed, judged invalid, and refused
54
+ * before any upstream call. The payload is deterministic: a retry sends
55
+ * identical bytes and earns an identical refusal. Telling someone to wait out
56
+ * a rejection is telling them to wait for a defect in cruxy to fix itself.
57
+ */
58
+ ApiRequestRejected: "CRUXY_E_API_REQUEST_REJECTED",
47
59
  ApiRateLimit: "CRUXY_E_API_RATE_LIMIT",
48
60
  ApiOverloaded: "CRUXY_E_API_OVERLOADED",
49
61
  BudgetExhausted: "CRUXY_E_BUDGET_EXHAUSTED",
@@ -289,6 +301,11 @@ const EXIT_CODES = {
289
301
  [ErrorCode.GatewayUnreachable]: 5,
290
302
  [ErrorCode.GitPushFailed]: 5,
291
303
  [ErrorCode.Api]: 6,
304
+ // Still the API exit class: the failure arrived from the gateway, and a script
305
+ // wrapping cruxy should treat it the same way it treats any other API failure.
306
+ // The difference this code carries is what a HUMAN should do about it, which
307
+ // is the next steps, not the exit status.
308
+ [ErrorCode.ApiRequestRejected]: 6,
292
309
  [ErrorCode.ApiRateLimit]: 6,
293
310
  [ErrorCode.ApiOverloaded]: 6,
294
311
  [ErrorCode.BudgetExhausted]: 6,
@@ -1,8 +1,13 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { globToRegexBody, isBinary } from "./util.js";
4
- /** Directories never descended into, regardless of ignore files. */
5
- const ALWAYS_IGNORE_DIRS = new Set([".git", "node_modules", ".cruxy"]);
4
+ /**
5
+ * Directories never descended into, regardless of ignore files. Exported because
6
+ * `checkpoint/coverage.ts` pairs each name with the reason a checkpoint cannot
7
+ * restore what is under it — a name added here without a reason there fails the
8
+ * build, so the walker cannot quietly widen what the ceiling still calls safe.
9
+ */
10
+ export const ALWAYS_IGNORE_DIRS = new Set([".git", "node_modules", ".cruxy"]);
6
11
  const DEFAULT_IGNORE_FILES = [".gitignore", ".cruxyignore"];
7
12
  /**
8
13
  * Hard denylist for secret-bearing files. Applied independently of ignore files
@@ -8,4 +8,4 @@
8
8
  * rendering lives in `tui/limits-panel.ts` — nothing here formats anything.
9
9
  */
10
10
  export { LimitsCache } from "./cache.js";
11
- export { reduceLimits, bindingWindow, usedFraction } from "./reduce.js";
11
+ export { reduceLimits, bindingWindow, scarcestWindow, usedFraction, } from "./reduce.js";
@@ -144,15 +144,26 @@ export function reduceLimits(res, readAt = Date.now()) {
144
144
  readAt,
145
145
  };
146
146
  }
147
+ /**
148
+ * TWO SELECTORS, TWO QUESTIONS. {@link bindingWindow} answers *which window will
149
+ * stop you first*; {@link scarcestWindow} answers *how much may you actually
150
+ * spend right now*. They are not the same question and they do not always pick
151
+ * the same window, so merging them would make one of the two answers wrong —
152
+ * see each function for which.
153
+ */
147
154
  /**
148
155
  * The window that will stop this user FIRST — the one the gate itself calls
149
156
  * binding: whichever of monthly/burst has the smaller REMAINING fraction
150
157
  * (`internal/budget/decide.go`).
151
158
  *
152
- * This is what earns the single bar the rail has room for. Drawing the month
153
- * because it is the bigger number would routinely show a comfortable 6% while
154
- * the trailing-12h window — a quarter of the month's cap on every self-serve
155
- * tier — is the one about to refuse the next request.
159
+ * FOR DISPLAY. This is what earns the single bar the rail has room for. Drawing
160
+ * the month because it is the bigger number would routinely show a comfortable
161
+ * 6% while the trailing-12h window — a quarter of the month's cap on every
162
+ * self-serve tier — is the one about to refuse the next request.
163
+ *
164
+ * The fraction is right HERE because a bar is a fraction: the pixels state
165
+ * proportion, so the window drawn must be the one whose proportion is the
166
+ * warning. It is wrong for admission, and {@link scarcestWindow} says why.
156
167
  */
157
168
  export function bindingWindow(monthly, burst) {
158
169
  if (!monthly)
@@ -164,6 +175,37 @@ export function bindingWindow(monthly, burst) {
164
175
  ? { window: burst, name: "burst" }
165
176
  : { window: monthly, name: "month" };
166
177
  }
178
+ /**
179
+ * The window with the least ABSOLUTE remaining — the ceiling an admission check
180
+ * must respect (cli#212).
181
+ *
182
+ * FOR ADMISSION. BOTH windows gate every request, so a draw of D is admitted
183
+ * only when `D ≤ monthly.remaining` AND `D ≤ burst.remaining` — which makes the
184
+ * spendable allowance exactly `min(remaining)`, a number of tokens rather than
185
+ * a proportion.
186
+ *
187
+ * WHY {@link bindingWindow} CANNOT SERVE HERE, concretely: a Core user 5% into
188
+ * the month (700k of 14M left) whose burst is at 14% (500k of 3.5M left). The
189
+ * fraction rule names the month, correctly — 5% is the figure worth showing.
190
+ * But handing 700k to an admission check authorises a batch the burst will
191
+ * refuse at 500k, and it refuses it AFTER the tokens are spent, against a
192
+ * sliding window that refills only by trickle over twelve hours. The two rules
193
+ * disagree exactly when the caps differ by 4×, which is to say on every
194
+ * self-serve tier, in the ordinary case rather than a corner one.
195
+ *
196
+ * The error is one-directional, which is why it is worth two functions: erring
197
+ * toward the smaller remainder narrows a fan-out that might have fitted, and
198
+ * erring toward the larger one spends a window that cannot be un-spent.
199
+ */
200
+ export function scarcestWindow(monthly, burst) {
201
+ if (!monthly)
202
+ return burst ? { window: burst, name: "burst" } : undefined;
203
+ if (!burst)
204
+ return { window: monthly, name: "month" };
205
+ return burst.remaining < monthly.remaining
206
+ ? { window: burst, name: "burst" }
207
+ : { window: monthly, name: "month" };
208
+ }
167
209
  /** The fraction of a cap consumed, clamped to [0,1] for display. */
168
210
  export function usedFraction(w) {
169
211
  if (!usableCap(w.cap))
@@ -1,3 +1,4 @@
1
+ import { MAX_SCHEMA_DEPTH, schemaDepth } from "../tools/schema-depth.js";
1
2
  const PERMISSIVE_SCHEMA = {
2
3
  type: "object",
3
4
  additionalProperties: true,
@@ -5,9 +6,16 @@ const PERMISSIVE_SCHEMA = {
5
6
  /**
6
7
  * Apply the bounds to a raw `tools/list`. Over the count cap → keep the first N
7
8
  * (by advertised order) and report `droppedCount`. Per tool: an over-long
8
- * description is truncated with a marker; an over-size input schema is replaced
9
- * with a permissive `object` schema and a note (we never forward an unbounded
10
- * schema, but we also never claim the args are constrained when we dropped it).
9
+ * description is truncated with a marker; an over-size OR over-deep input schema
10
+ * is replaced with a permissive `object` schema and a note (we never forward an
11
+ * unbounded or unsendable schema, but we also never claim the args are
12
+ * constrained when we dropped it).
13
+ *
14
+ * Degrading beats dying, and it is the same trade the byte cap already makes: an
15
+ * over-deep tool stays callable with unconstrained args and a visible note,
16
+ * rather than taking the session down with it. Nothing is lost locally by the
17
+ * swap — the adapter delegates arg validation to the server either way
18
+ * (`PASSTHROUGH`), and the approval gate is untouched.
11
19
  */
12
20
  export function boundToolList(tools, bounds) {
13
21
  const kept = tools.slice(0, bounds.maxTools);
@@ -30,6 +38,16 @@ export function boundToolList(tools, bounds) {
30
38
  inputSchema = { ...PERMISSIVE_SCHEMA };
31
39
  notes.push(`input schema (${schemaBytes} bytes) exceeded the ${bounds.maxSchemaBytes}-byte cap and was replaced with a permissive one`);
32
40
  }
41
+ // ORDER MATTERS: depth is measured on the POST-byte-cap value. A schema the
42
+ // byte cap already replaced is permissive (depth 1) and passes trivially, so
43
+ // the only schema this counter ever walks is one that fit `maxSchemaBytes` —
44
+ // which is what bounds the walk. Measuring the raw schema first would hand a
45
+ // hostile server an unbounded one.
46
+ const depth = schemaDepth(inputSchema);
47
+ if (depth >= MAX_SCHEMA_DEPTH) {
48
+ inputSchema = { ...PERMISSIVE_SCHEMA };
49
+ notes.push(`input schema nests ${depth} levels deep, at or over the ${MAX_SCHEMA_DEPTH}-level provider limit, and was replaced with a permissive one`);
50
+ }
33
51
  return { name: t.name, description, inputSchema, notes };
34
52
  });
35
53
  return { tools: bounded, droppedCount };
@@ -5,11 +5,23 @@
5
5
  * for the rest of the run, recording the real scope in the shared
6
6
  * {@link SessionAllowlist} so U.3's own scoping (`scopeCovers`) governs reuse.
7
7
  *
8
- * What it deliberately does NOT do — the two-tier boundary:
8
+ * What it deliberately does NOT do — the boundary, in three parts:
9
9
  * • **destructive** actions always fall through to the base policy (prompt);
10
10
  * • **ungrantable** actions (`scope:"none"`) always fall through (prompt),
11
11
  * even if the tier is `mutate` — because `SessionAllowlist.grant` is a no-op
12
- * on `none`, an ungrantable action can never be pre-approved.
12
+ * on `none`, an ungrantable action can never be pre-approved;
13
+ * • **irreversible** actions always fall through (prompt), even if the tier is
14
+ * `mutate` AND the scope is grantable (cli#193). This is the only one of the
15
+ * three that `tier` cannot express: a write to `/etc/passwd` is tier
16
+ * `mutate` with a perfectly good exact-file scope, and the checkpoint still
17
+ * cannot restore it. Without this clause, "approve + allow safe steps"
18
+ * silently authorized exactly that for the rest of the run.
19
+ *
20
+ * That last clause is why it is checked HERE rather than left to the allowlist.
21
+ * The ceiling in `SessionAllowlist.allows` covers grants being *spent*; this
22
+ * branch never asks `allows()` — it is pre-consent, so it *writes* a grant. It
23
+ * is the one path into the allowlist that the chokepoint cannot defend, so it
24
+ * carries its own copy of the same rule.
13
25
  *
14
26
  * So approving a plan (even with "allow safe steps") never consents to a
15
27
  * destructive or irreversible action; those re-confirm at execution time.
@@ -27,14 +39,18 @@ export class PlanExecutionPolicy {
27
39
  this.safeStepGrants = true;
28
40
  }
29
41
  async decide(request) {
30
- // Already covered by a prior session grant (scoped, tier-keyed) allow.
42
+ // Already covered by a prior session grant (scoped, tier-keyed, and — since
43
+ // cli#193 — reversible; `allows` enforces the ceiling itself) → allow.
31
44
  if (this.allowlist.allows(request))
32
45
  return { allow: true };
33
- // The one plan-mode shortcut: a grantable mutate action, pre-consented by
34
- // the "allow safe steps" choice. Record its concrete scope, then allow.
46
+ // The one plan-mode shortcut: a grantable, REVERSIBLE mutate action,
47
+ // pre-consented by the "allow safe steps" choice. Record its concrete scope,
48
+ // then allow. `!request.irreversible` is load-bearing, not defensive — see
49
+ // the third bullet in this class's doc comment.
35
50
  if (this.safeStepGrants &&
36
51
  request.tier === "mutate" &&
37
- request.scope.kind !== "none") {
52
+ request.scope.kind !== "none" &&
53
+ !request.irreversible) {
38
54
  this.allowlist.grant(request);
39
55
  return { allow: true };
40
56
  }
@@ -0,0 +1,88 @@
1
+ import { fit, reflow, visibleWidth } from "./layout.js";
2
+ /**
3
+ * The ceiling, in one line.
4
+ *
5
+ * Stated as an ABSOLUTE, because since cli#193 it is one: `allows()` checks
6
+ * `irreversible` before it matches any scope, so there is no longer a "unless
7
+ * you granted it" clause to add. #196 asked this screen to be honest about the
8
+ * asymmetry; the honest version today is that the asymmetry is closed, and the
9
+ * cost — a granted `git` not covering `git commit` — is what the inert marks
10
+ * and the re-prompt copy explain.
11
+ */
12
+ const CEILING = "irreversible actions always ask — in every mode, and no session grant outranks it";
13
+ /**
14
+ * How wide the label column may grow. A grant label is usually short (`git
15
+ * commands`, `changes under src/`), but an exact-file grant or a test grant
16
+ * carries a path or a whole command, and one long row must not cost every other
17
+ * row its tier and root.
18
+ */
19
+ const LABEL_MAX_COLS = 34;
20
+ /** `key value`, on the same gutter `/status` uses so the two screens align. */
21
+ function row(key, value, t) {
22
+ return ` ${t.muted(key.padEnd(11))} ${value}`;
23
+ }
24
+ /** `2 standing · 1 inert`, or what an empty/unwired list has to say instead. */
25
+ function grantSummary(grants, t) {
26
+ if (!grants)
27
+ return t.muted("not tracked in this session");
28
+ if (grants.length === 0) {
29
+ return t.muted("none — every action has been decided one at a time");
30
+ }
31
+ const dead = grants.filter((g) => g.dead).length;
32
+ const live = grants.length - dead;
33
+ const head = `${live} standing`;
34
+ return dead === 0
35
+ ? head
36
+ : `${head} ${t.warning(`${t.glyph.sep} ${dead} inert`)}`;
37
+ }
38
+ /** The full `/permissions` block as lines to print. */
39
+ export function permissionsReportLines(report, t, width = Infinity) {
40
+ const lines = [t.heading("permissions")];
41
+ lines.push(row("mode", `${t.strong(report.mode)} ${t.muted(`— ${report.modeDescription}`)}`, t));
42
+ // The ceiling is the one row that is the same in every session, and it is here
43
+ // for exactly that reason: a screen listing what runs without asking has to
44
+ // state what asks anyway, next to it, or the list reads as the whole answer.
45
+ lines.push(row("ceiling", report.autoApproves ? t.warning(CEILING) : t.muted(CEILING), t));
46
+ lines.push(row("grants", grantSummary(report.grants, t), t));
47
+ if (report.grants && report.grants.length > 0) {
48
+ lines.push("");
49
+ // One label column, sized to the longest label the list actually holds and
50
+ // capped so a pathological grant (a long exact path, a long test command)
51
+ // cannot push the tier and the root off a normal terminal. Padded on the
52
+ // VISIBLE width, because a coloured label carries escape bytes that
53
+ // `padEnd` would count and the terminal would not.
54
+ const labelCols = Math.min(LABEL_MAX_COLS, Math.max(...report.grants.map((g) => visibleWidth(g.label))));
55
+ for (const g of report.grants) {
56
+ const n = t.muted(`${g.n}`.padStart(2));
57
+ const text = fit(g.label, labelCols, t.glyph.ellipsis);
58
+ const pad = " ".repeat(Math.max(0, labelCols - visibleWidth(text)));
59
+ const label = g.dead ? t.muted(text) : t.strong(text);
60
+ lines.push(` ${n} ${label}${pad} ${t.muted(`${g.tier} ${t.glyph.sep} ${g.where}`)}`);
61
+ if (!g.dead)
62
+ continue;
63
+ // Why it can never match, on its own rows so the reason survives a narrow
64
+ // terminal intact — an inert grant the user cannot read the reason for is
65
+ // the same puzzle as an unmarked one.
66
+ const indent = " ";
67
+ const room = Number.isFinite(width)
68
+ ? Math.max(20, width - indent.length)
69
+ : Infinity;
70
+ const reason = `inert — ${g.dead}`;
71
+ const wrapped = Number.isFinite(room) ? reflow(reason, room) : [reason];
72
+ for (const line of wrapped)
73
+ lines.push(`${indent}${t.warning(line)}`);
74
+ }
75
+ }
76
+ lines.push("");
77
+ // THE SCOPE OF THE SCREEN. Every background job builds its own
78
+ // ApprovalService with its own allowlist, so a job's grants are neither
79
+ // listed here nor revoked by anything typed here. A permissions screen that
80
+ // implied otherwise would be understating what the session can still do.
81
+ lines.push(t.muted("this is the foreground session only — each background job keeps its own grants"));
82
+ if (report.grants && report.grants.length > 0) {
83
+ lines.push(t.muted("/permissions revoke <n> drops one — actions already decided keep their decision"));
84
+ }
85
+ return Number.isFinite(width)
86
+ ? lines.map((l) => fit(l, width, t.glyph.ellipsis))
87
+ : lines;
88
+ }
@@ -43,9 +43,28 @@ export function describePhase(phase, glyph = UNICODE_GLYPHS) {
43
43
  const base = t && t.input + t.output > 0
44
44
  ? `thinking${e} ${glyph.sep} tokens ${glyph.caretUp}${formatTokens(t.input)} ${glyph.caretDown}${formatTokens(t.output)}`
45
45
  : `thinking${e}`;
46
- // The real routing tier (C.30), appended honestly when present same
47
- // ` · ` joiner as the token counts.
48
- return phase.tier ? `${base} ${glyph.sep} ${phase.tier}` : base;
46
+ // The real routing tier (C.30) and the effort the gateway says it applied,
47
+ // appended honestly when present — same ` · ` joiner as the token counts.
48
+ //
49
+ // Order matters under truncation. `fitStatusLine`'s last resort clips the
50
+ // TAIL, so what is appended last is shed first: effort goes before the
51
+ // tier, which goes before the token counts, which go before "thinking…".
52
+ // That is the intended priority — the tier answers "what ran", effort only
53
+ // refines it.
54
+ //
55
+ // Effort is labelled rather than shown bare. `kavi · none` beside a tier
56
+ // name reads as a second tier; `kavi · effort none` cannot. The label
57
+ // costs seven columns and buys the line's only defence against being
58
+ // misread, which is worth more here than seven columns are.
59
+ const parts = [base];
60
+ if (phase.tier)
61
+ parts.push(phase.tier);
62
+ // A present `none` is the gateway SAYING no reasoning ran — a real answer
63
+ // to "what actually ran", and the one this readout exists to make
64
+ // visible. Absent stays absent; see ServedRouting.reasoningEffort.
65
+ if (phase.effort)
66
+ parts.push(`effort ${phase.effort}`);
67
+ return parts.join(` ${glyph.sep} `);
49
68
  }
50
69
  case "calling-tool":
51
70
  return `${phase.label}${e}`;