@tokenoftrust/cli 2.0.12 → 2.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/tot.mjs CHANGED
@@ -21,6 +21,12 @@ import { printError } from "../src/errors.mjs";
21
21
  import { recordActivity, redactArgs } from "../src/activity-log.mjs";
22
22
  import { emitActivity, capRendered } from "../src/activity.mjs";
23
23
  import { maybeNotifyUpdate } from "../src/update-check.mjs";
24
+ import {
25
+ currentInvocationDiagnostics,
26
+ hasRequiredInvocationDiagnostic,
27
+ recordDiagnostic,
28
+ resetInvocationDiagnostics,
29
+ } from "../src/diagnostics.mjs";
24
30
 
25
31
  const BUILD_ORDER = ["clone", "validate", "dev", "preview"];
26
32
 
@@ -305,6 +311,7 @@ async function main() {
305
311
  const ctx = detectContext();
306
312
  const startedAt = Date.now();
307
313
  const command = cmd || "(none)";
314
+ resetInvocationDiagnostics({ command, cliVersion: VERSION });
308
315
  const subcommand = safeSubcommand(cmd, rest);
309
316
  let code = 0;
310
317
  let errMsg = null;
@@ -323,10 +330,18 @@ async function main() {
323
330
  return code;
324
331
  } catch (e) {
325
332
  errMsg = e?.message || String(e);
333
+ recordDiagnostic(e, { command, operation: command, cliVersion: VERSION, required: true });
326
334
  throw e;
327
335
  } finally {
328
- const exitCode = errMsg ? 1 : (code ?? 0);
336
+ const exitCode = errMsg ? 1 : (Number.isInteger(code) ? code : 0);
329
337
  const durationMs = Date.now() - startedAt;
338
+ if (exitCode !== 0 && !hasRequiredInvocationDiagnostic()) {
339
+ recordDiagnostic(new Error(errMsg || `command exited ${exitCode}`), {
340
+ command, operation: command, category: "handled_exit", required: true,
341
+ });
342
+ }
343
+ const diagnostics = currentInvocationDiagnostics();
344
+ const diagnosticIds = diagnostics.map((entry) => entry.diagnosticId).filter(Boolean);
330
345
  // Best-effort activity breadcrumb (never throws, never blocks). `feedback`'s own
331
346
  // free-text message is omitted — it's user-typed and belongs only in the report.
332
347
  recordActivity({
@@ -337,6 +352,7 @@ async function main() {
337
352
  code: exitCode,
338
353
  ms: durationMs,
339
354
  ...(errMsg ? { err: String(errMsg).slice(0, 200) } : {}),
355
+ ...(diagnosticIds.length ? { diagnosticId: diagnosticIds.at(-1), diagnosticIds } : {}),
340
356
  });
341
357
  // Emit `cli.command.result` (exit code + duration + a bounded/redacted
342
358
  // rendered field). The house-style error text is capped + run through the JS
@@ -347,9 +363,12 @@ async function main() {
347
363
  // when there's no credential.
348
364
  const resultEmit = emitActivity({
349
365
  action: "cli.command.result",
350
- outcome: { status: errMsg ? "failed" : "succeeded", durationMs },
366
+ outcome: { status: exitCode === 0 ? "succeeded" : "failed", durationMs },
351
367
  payload: {
352
- args: { command, ...(subcommand ? { subcommand } : {}), cliVersion: VERSION, exitCode, durationMs },
368
+ args: {
369
+ command, ...(subcommand ? { subcommand } : {}), cliVersion: VERSION,
370
+ exitCode, durationMs, ...(diagnosticIds.length ? { diagnosticId: diagnosticIds.at(-1) } : {}),
371
+ },
353
372
  ...(errMsg ? { rendered: { output: capRendered(errMsg) } } : {}),
354
373
  },
355
374
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.0.12",
3
+ "version": "2.0.14",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -4,7 +4,8 @@
4
4
  * (sent through the Token of Trust MCP's `feedback_submit` tool).
5
5
  *
6
6
  * ONE file, `~/.tot/activity.log` (JSON-lines), beside the credential cache. Each
7
- * line is one command invocation: { ts, v, cmd, args, code, ms, err? }. Bounded to
7
+ * line is one command invocation: { ts, v, cmd, args, code, ms, err?, diagnosticId?,
8
+ * diagnosticIds? }. Bounded to
8
9
  * the most recent MAX_ENTRIES so it never grows without limit.
9
10
  *
10
11
  * NEVER records secrets: the value after `--code` / `--token` (the single-use
package/src/activity.mjs CHANGED
@@ -63,7 +63,7 @@ export const CLI_ACTION_CATALOG = {
63
63
  renderedAllow: [],
64
64
  },
65
65
  "cli.command.result": {
66
- argsAllow: ["command", "subcommand", "cliVersion", "exitCode", "durationMs"],
66
+ argsAllow: ["command", "subcommand", "cliVersion", "exitCode", "durationMs", "diagnosticId"],
67
67
  renderedAllow: [],
68
68
  },
69
69
  "cli.obstacle.reported": {
@@ -55,6 +55,7 @@ import { CliError, fail, formatError } from "../errors.mjs";
55
55
  import { writeNvmrc } from "../sample.mjs";
56
56
  import { emitObstacle } from "../obstacle.mjs";
57
57
  import { splitAuthedRemote, basicAuthExtraHeader, installForgeCredentialHelper } from "../git-credential.mjs";
58
+ import { recordDiagnostic } from "../diagnostics.mjs";
58
59
 
59
60
  const execFileP = promisify(execFile);
60
61
 
@@ -364,6 +365,7 @@ async function cloneRepo(gitRemote, dir, redact) {
364
365
  await emitObstacle("clone-failed");
365
366
  throw new CliError(`clone failed: ${redact(String(e.stderr || e.message || e))}`, {
366
367
  next: `check the target dir is empty and you can reach the remote, then re-run`,
368
+ cause: e,
367
369
  });
368
370
  }
369
371
  // Install the HOST-SCOPED helper, not a bare `credential.helper`. A bare one is appended to
@@ -376,9 +378,12 @@ async function cloneRepo(gitRemote, dir, redact) {
376
378
  const gitSync = (/** @type {string[]} */ cargs) =>
377
379
  execFileSync("git", ["-C", dir, ...cargs], { encoding: "utf8" });
378
380
  installForgeCredentialHelper(gitSync, { host: new URL(gitRemote).host });
379
- } catch {
381
+ } catch (error) {
380
382
  // Best-effort — never fail a good clone over credential plumbing. The next
381
383
  // `tot preview`/`tot sync` installs it, and the fetch that needs it says why.
384
+ recordDiagnostic(error, {
385
+ command: "clone", operation: "credential_helper_install", required: false, degraded: true,
386
+ });
382
387
  }
383
388
  const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
384
389
  writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
@@ -53,6 +53,7 @@ import {
53
53
  import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
54
54
  import { streamDevLogs } from "../dev-logs.mjs";
55
55
  import { cockpitUrlFrom } from "../banner.mjs";
56
+ import { recordDiagnostic } from "../diagnostics.mjs";
56
57
 
57
58
  /** The published runner image (--docker fallback). Override with --image / TOT_DEV_IMAGE. */
58
59
  const DEFAULT_DEV_IMAGE =
@@ -874,7 +875,10 @@ export function probeRunnerVersion(runnerDir, { timeoutMs = 4000 } = {}) {
874
875
  });
875
876
  const v = String(out).trim().split(/\s+/)[0];
876
877
  return /^\d+\.\d+\.\d+/.test(v) ? v : null;
877
- } catch {
878
+ } catch (error) {
879
+ recordDiagnostic(error, {
880
+ command: "dev", operation: "runner_version_probe", required: false, degraded: true,
881
+ });
878
882
  return null; // old runner (no --version), timeout, or spawn failure → treat as unversioned
879
883
  }
880
884
  }
@@ -1323,7 +1327,10 @@ export async function publishedRunnerIntegrity(env, version, { timeoutMs = 2000,
1323
1327
  if (!res.ok) return null;
1324
1328
  const dist = (await res.json())?.versions?.[version]?.dist;
1325
1329
  return dist?.integrity || dist?.shasum || null;
1326
- } catch {
1330
+ } catch (error) {
1331
+ recordDiagnostic(error, {
1332
+ command: "dev", operation: "runner_integrity_probe", required: false, degraded: true,
1333
+ }, env);
1327
1334
  return null;
1328
1335
  }
1329
1336
  }
@@ -1809,6 +1816,9 @@ export async function ensureRegistryLogin(image, args, { client: providedClient
1809
1816
  });
1810
1817
  console.error(`~ registry sign-in ok (${reg})`);
1811
1818
  } catch (e) {
1819
+ recordDiagnostic(e, {
1820
+ command: "dev", operation: "dev_image_pull_token", required: false, degraded: true,
1821
+ });
1812
1822
  console.error(
1813
1823
  `~ (using existing docker login for ${registry} — MCP pull-token not available: ${String(e?.message || e)})`,
1814
1824
  );
@@ -26,6 +26,7 @@ import { establishSession, AuthUnavailableError } from "../auth.mjs";
26
26
  import { readActivity, formatActivity } from "../activity-log.mjs";
27
27
  import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
28
28
  import { fail } from "../errors.mjs";
29
+ import { formatDiagnostics, readDiagnostics, recordDiagnostic } from "../diagnostics.mjs";
29
30
 
30
31
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
31
32
  const TYPES = new Set(["bug", "feature", "improvement"]);
@@ -57,9 +58,14 @@ Requires a signed-in session — run \`tot login\` first if needed.`;
57
58
  * `traceId` link) is unit-testable without driving the MCP handshake. Both extras
58
59
  * are OMITTED when their source is absent — never sent as null/empty.
59
60
  * @param {{ type: string, category: string, severity: string, title: string,
60
- * description: string, activityText?: string|null, traceId?: string|null }} f
61
+ * description: string, activityText?: string|null, diagnosticsText?: string|null,
62
+ * traceId?: string|null }} f
61
63
  */
62
- export function buildFeedbackPayload({ type, category, severity, title, description, activityText, traceId }) {
64
+ export function buildFeedbackPayload({ type, category, severity, title, description, activityText, diagnosticsText, traceId }) {
65
+ const attachments = [
66
+ activityText ? `tot CLI activity (most recent last):\n${activityText}` : null,
67
+ diagnosticsText ? `tot CLI diagnostics (redacted, most recent last):\n${diagnosticsText}` : null,
68
+ ].filter(Boolean);
63
69
  return {
64
70
  type,
65
71
  category,
@@ -67,9 +73,9 @@ export function buildFeedbackPayload({ type, category, severity, title, descript
67
73
  title,
68
74
  description,
69
75
  scenario: "tot-cli",
70
- // Activity → `sensitive` (ToT-admins-only, never clustered/shared). It's already
71
- // secret-redacted; `sensitive` is the belt-and-suspenders home for it.
72
- ...(activityText ? { sensitive: `tot CLI activity (most recent last):\n${activityText}` } : {}),
76
+ // CLI context → `sensitive` (ToT-admins-only, never clustered/shared). It is
77
+ // redacted before attachment; `sensitive` is the belt-and-suspenders home for it.
78
+ ...(attachments.length ? { sensitive: attachments.join("\n\n") } : {}),
73
79
  // Invite→terminal→problem trace link (omitted when this wasn't an invite login).
74
80
  ...(traceId ? { traceId } : {}),
75
81
  };
@@ -138,6 +144,8 @@ export async function run(argv) {
138
144
 
139
145
  const entries = args.activity ? readActivity(env, { limit: 40 }) : [];
140
146
  const activityText = entries.length ? formatActivity(entries) : null;
147
+ const diagnostics = args.activity ? readDiagnostics(env, { limit: 20 }) : [];
148
+ const diagnosticsText = diagnostics.length ? formatDiagnostics(diagnostics) : null;
141
149
  const title = message.length > 80 ? `${message.slice(0, 79)}…` : message;
142
150
 
143
151
  // The invite→terminal→problem trace id `tot login` cached from the pasted invite
@@ -153,9 +161,9 @@ export async function run(argv) {
153
161
  console.error(` ${args.type} · ${args.category} · ${args.severity}`);
154
162
  console.error(` "${title}"`);
155
163
  console.error(
156
- activityText
157
- ? ` + your last ${entries.length} CLI command(s) — secret-redacted, ToT-admins-only`
158
- : " (no activity log attached)",
164
+ activityText || diagnosticsText
165
+ ? ` + ${entries.length} recent command(s) and ${diagnostics.length} diagnostic(s) — redacted, ToT-admins-only`
166
+ : " (no activity or diagnostics attached)",
159
167
  );
160
168
 
161
169
  if (!args.yes) {
@@ -192,6 +200,7 @@ export async function run(argv) {
192
200
  title,
193
201
  description: message,
194
202
  activityText,
203
+ diagnosticsText,
195
204
  traceId,
196
205
  });
197
206
  const res = await client.callTool("feedback_submit", payload);
@@ -199,6 +208,7 @@ export async function run(argv) {
199
208
  console.log(`\n+ sent — thank you.${id ? ` (report ${id})` : ""}`);
200
209
  return 0;
201
210
  } catch (e) {
211
+ recordDiagnostic(e, { command: "feedback", operation: "feedback_submit", required: true }, env);
202
212
  if (e instanceof AuthUnavailableError) {
203
213
  console.error(fail(`can't send feedback: ${e.message}`, e.hint || "run `tot login` first."));
204
214
  return 1;
@@ -29,6 +29,7 @@ import { createMcpClient } from "../mcp.mjs";
29
29
  import { storeListError, noStoresGuidance } from "./clone.mjs";
30
30
  import { offerSignIn } from "./login.mjs";
31
31
  import { recordServerPolicy } from "../update-check.mjs";
32
+ import { recordDiagnostic } from "../diagnostics.mjs";
32
33
 
33
34
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
34
35
 
@@ -169,7 +170,10 @@ async function tryIntrospect(client) {
169
170
  const r = await client.callTool("grant_introspect", {});
170
171
  if (r && typeof r === "object" && Array.isArray(r.grants)) return r;
171
172
  return null;
172
- } catch {
173
+ } catch (error) {
174
+ recordDiagnostic(error, {
175
+ command: "grants", operation: "grant_introspect", required: false, degraded: true,
176
+ });
173
177
  return null;
174
178
  }
175
179
  }
@@ -25,6 +25,7 @@ import { offerSignIn } from "./login.mjs";
25
25
  import { openBrowser } from "../open.mjs";
26
26
  import { CliError, fail, formatError } from "../errors.mjs";
27
27
  import { normalizeStores } from "./clone.mjs";
28
+ import { recordDiagnostic } from "../diagnostics.mjs";
28
29
 
29
30
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
30
31
 
@@ -131,6 +132,7 @@ export async function run(argv, _ctx) {
131
132
  } catch (e) {
132
133
  throw new CliError(`couldn't start the identity link: ${String(e?.message || e)}`, {
133
134
  next: "your MCP may not support `tot link` yet — run `tot whoami` for the current guidance",
135
+ cause: e,
134
136
  });
135
137
  }
136
138
  const { authUrl, pollHandle } = linkBeginFields(begin);
@@ -172,7 +174,10 @@ export async function run(argv, _ctx) {
172
174
  } else {
173
175
  console.log(" Next: `tot start` — if it still shows no stores, ask your ToT contact for a store invite.");
174
176
  }
175
- } catch {
177
+ } catch (error) {
178
+ recordDiagnostic(error, {
179
+ command: "link", operation: "client_list", required: false, degraded: true,
180
+ }, env);
176
181
  console.log(" Next: `tot start` to build your store.");
177
182
  }
178
183
  return 0;
@@ -205,6 +210,9 @@ export async function pollLink(client, pollHandle, { timeoutMs = 120000, interva
205
210
  res = await client.callTool("identity_link_poll", { pollHandle });
206
211
  } catch (e) {
207
212
  // A transient poll error isn't fatal — keep trying until the deadline.
213
+ recordDiagnostic(e, {
214
+ command: "link", operation: "identity_link_poll", required: false, degraded: true,
215
+ });
208
216
  await delay(intervalMs);
209
217
  continue;
210
218
  }
@@ -17,7 +17,8 @@
17
17
  * exits non-zero and keeps its stable retry identity. Step 3 calls
18
18
  * the MCP `preview_status` read-back: given the commit just pushed it returns
19
19
  * { status, reconcile, compliance, previewUrl } and we poll it while reconcile is
20
- * pending. A read-back failure after the candidate exists is reported separately.
20
+ * pending. A later read-back failure preserves the candidate receipt and returns a
21
+ * successful but explicitly degraded result with a diagnostic ID.
21
22
  *
22
23
  * Polling (E2): every call carries `waitMs` so a preview_status-aware MCP long-polls
23
24
  * (blocks up to waitMs, waking immediately on arrival) instead of us sleeping blind
@@ -53,6 +54,11 @@ import { openBrowser } from "../open.mjs";
53
54
  import { startProgress } from "../progress.mjs";
54
55
  import { fail } from "../errors.mjs";
55
56
  import { emitActivity } from "../activity.mjs";
57
+ import {
58
+ diagnosticReference,
59
+ invocationDegradedResult,
60
+ recordDiagnostic,
61
+ } from "../diagnostics.mjs";
56
62
  import {
57
63
  defaultCandidateStatePath,
58
64
  readActiveChangeId,
@@ -500,7 +506,13 @@ export async function runBornRebased(client, { repo, changeId, strategy }) {
500
506
  const raw = await client.callTool("candidate_refresh", { repo, changeId, strategy });
501
507
  return normalizeRefreshResult(raw);
502
508
  } catch (e) {
503
- return { ...normalizeRefreshResult(null), status: "error", message: String(e?.message || e) };
509
+ const diagnostic = recordDiagnostic(e, {
510
+ command: "submit", operation: "candidate_refresh", required: false, degraded: true,
511
+ });
512
+ return {
513
+ ...normalizeRefreshResult(null), status: "error", message: String(e?.message || e),
514
+ degraded: true, diagnosticId: diagnostic.diagnosticId,
515
+ };
504
516
  }
505
517
  }
506
518
 
@@ -959,7 +971,10 @@ export async function candidateStateFor(client, { repo, changeId }) {
959
971
  ? r.candidates.find((x) => x?.changeId === changeId)
960
972
  : r;
961
973
  return c && typeof c.state === "string" ? c.state : null;
962
- } catch {
974
+ } catch (error) {
975
+ recordDiagnostic(error, {
976
+ command: "submit", operation: "candidate_status", required: false, degraded: true,
977
+ });
963
978
  return null;
964
979
  }
965
980
  }
@@ -1012,8 +1027,8 @@ const REQUIRED_ATTRIBUTION_REFUSALS = new Set([
1012
1027
  ]);
1013
1028
 
1014
1029
  export class CandidateRegistrationError extends Error {
1015
- constructor(message) {
1016
- super(message);
1030
+ constructor(message, cause) {
1031
+ super(message, cause === undefined ? undefined : { cause });
1017
1032
  this.name = "CandidateRegistrationError";
1018
1033
  }
1019
1034
  }
@@ -1071,7 +1086,7 @@ export async function submitCandidate(client, { repo, changeId, ref, headSha, ch
1071
1086
  return result;
1072
1087
  } catch (e) {
1073
1088
  if (e instanceof CandidateRegistrationError) throw e;
1074
- throw new CandidateRegistrationError(`Candidate not created: ${describeReadbackError(e)}`);
1089
+ throw new CandidateRegistrationError(`Candidate not created: ${describeReadbackError(e)}`, e);
1075
1090
  }
1076
1091
  }
1077
1092
 
@@ -1120,9 +1135,10 @@ function reportCandidate(result, changeId, { quiet = false } = {}) {
1120
1135
  * status?: {status?: string, reconcile?: object|null, compliance?: object|null,
1121
1136
  * previewUrl?: string|null, shipped?: object|null, dispatched?: boolean|null,
1122
1137
  * notDispatched?: boolean, delivery?: object|null}|null,
1123
- * previewPrUrl?: string|null, error?: string|null, note?: string|null, noChanges?: boolean }} input
1138
+ * previewPrUrl?: string|null, error?: string|null, note?: string|null, noChanges?: boolean,
1139
+ * degraded?: boolean, diagnosticId?: string|null }} input
1124
1140
  */
1125
- export function buildJsonResult({ ok, ref = null, commit = null, changeId = null, candidate = null, status = null, previewPrUrl = null, error = null, note = null, noChanges = false }) {
1141
+ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null, candidate = null, status = null, previewPrUrl = null, error = null, note = null, noChanges = false, degraded = false, diagnosticId = null }) {
1126
1142
  return {
1127
1143
  ok,
1128
1144
  ref,
@@ -1145,6 +1161,8 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
1145
1161
  forwardFailed: /** @type {any} */ (status)?.forwardFailed ?? false,
1146
1162
  delivery: status?.delivery ?? null,
1147
1163
  previewPrUrl,
1164
+ ...(degraded ? { degraded: true } : {}),
1165
+ ...(diagnosticId ? { diagnosticId } : {}),
1148
1166
  // Honest-diagnosis parity with the human-readable formatNotDispatchedBlock: a
1149
1167
  // `--json` caller gets the SAME "why" signal a human sees on the console. Without
1150
1168
  // this, `candidate:null, notDispatched:true, delivery:null` reads identically for
@@ -1157,6 +1175,45 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
1157
1175
  };
1158
1176
  }
1159
1177
 
1178
+ export function candidateFailureOutcome({
1179
+ error, candidateRequired, candidate, operation, command, env,
1180
+ ref, commit, changeId, previewPrUrl,
1181
+ }) {
1182
+ const required = candidateRequired && !candidate;
1183
+ const diagnostic = recordDiagnostic(error, {
1184
+ command, operation, required, degraded: !required,
1185
+ }, env);
1186
+ if (required) {
1187
+ const detail = error instanceof CandidateRegistrationError
1188
+ ? error.message
1189
+ : String(error?.message || error);
1190
+ const note = `push succeeded but no review candidate was created: ${detail}`;
1191
+ return {
1192
+ required,
1193
+ exitCode: 1,
1194
+ note,
1195
+ diagnostic,
1196
+ result: buildJsonResult({
1197
+ ok: false, ref, commit, changeId, candidate: null, error: note,
1198
+ diagnosticId: diagnostic.diagnosticId,
1199
+ }),
1200
+ };
1201
+ }
1202
+ const note = error instanceof AuthUnavailableError
1203
+ ? `candidate created; sign in to read its reconcile/compliance result — ${error.hint || "developer sign-in pending"}`
1204
+ : `candidate created; result read-back is degraded: ${describeReadbackError(error)}`;
1205
+ return {
1206
+ required,
1207
+ exitCode: 0,
1208
+ note,
1209
+ diagnostic,
1210
+ result: buildJsonResult({
1211
+ ok: true, ref, commit, changeId, candidate, previewPrUrl, note,
1212
+ degraded: true, diagnosticId: diagnostic.diagnosticId,
1213
+ }),
1214
+ };
1215
+ }
1216
+
1160
1217
  /**
1161
1218
  * The sha `preview_status` actually resolves against. The candidate the forge opened
1162
1219
  * may carry a DIFFERENT head than the commit you pushed (it is rebuilt onto the
@@ -1272,8 +1329,10 @@ export async function run(argv, ctx, {
1272
1329
  // expired. Best-effort — never blocks the actual preview on a migration hiccup.
1273
1330
  try {
1274
1331
  ensureTokenlessRemote(git);
1275
- } catch {
1276
- /* best-effort — see above */
1332
+ } catch (error) {
1333
+ recordDiagnostic(error, {
1334
+ command: verb, operation: "credential_helper_install", required: false, degraded: true,
1335
+ }, env);
1277
1336
  }
1278
1337
 
1279
1338
  // How far the base has drifted since this candidate forked (unit c2), hoisted to the
@@ -1455,11 +1514,20 @@ export async function run(argv, ctx, {
1455
1514
  try {
1456
1515
  session = await establish(client, { env, prefer: args.identity || undefined });
1457
1516
  } catch (e) {
1517
+ const diagnostic = recordDiagnostic(e, {
1518
+ command: verb, operation: "session_establish", required: true, degraded: false,
1519
+ }, env);
1458
1520
  const note = e instanceof AuthUnavailableError
1459
1521
  ? `Candidate not created: ${e.hint || "developer sign-in is required"}.`
1460
1522
  : `Candidate not created: couldn't reach Token of Trust (${describeReadbackError(e)}).`;
1461
- if (!args.json) console.error(fail(note, "run `tot login`, then re-run; no candidate ref was pushed"));
1462
- emitJson(args, buildJsonResult({ ok: false, commit, error: note }));
1523
+ if (!args.json) {
1524
+ console.error(fail(note, "run `tot login`, then re-run; no candidate ref was pushed"));
1525
+ console.error(` → ${diagnosticReference(diagnostic, env)}`);
1526
+ }
1527
+ emitJson(args, buildJsonResult({
1528
+ ok: false, commit, candidate: null, error: note,
1529
+ diagnosticId: diagnostic.diagnosticId,
1530
+ }));
1463
1531
  return 1;
1464
1532
  }
1465
1533
 
@@ -1474,7 +1542,11 @@ export async function run(argv, ctx, {
1474
1542
  if (active && repo && !args.forkCandidate) {
1475
1543
  try {
1476
1544
  await client.callTool("client_switch", { tenant });
1477
- } catch { /* scope bind is best-effort; candidateStateFor tolerates a miss */ }
1545
+ } catch (error) {
1546
+ recordDiagnostic(error, {
1547
+ command: verb, operation: "client_switch", required: false, degraded: true,
1548
+ }, env);
1549
+ }
1478
1550
  const resolved = await resolveActivePointer(client, { repo, active });
1479
1551
  if (resolved.dropped) {
1480
1552
  console.error(
@@ -1521,10 +1593,18 @@ export async function run(argv, ctx, {
1521
1593
  }
1522
1594
  try {
1523
1595
  writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
1524
- } catch {
1596
+ } catch (error) {
1525
1597
  const note = "Candidate not created: couldn't persist retry identity.";
1526
- if (!args.json) console.error(fail(note, "fix ~/.tot permissions, then re-run; no candidate ref was pushed"));
1527
- emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
1598
+ const diagnostic = recordDiagnostic(error, {
1599
+ command: verb, operation: "candidate_pointer_write", required: true, degraded: false,
1600
+ }, env);
1601
+ if (!args.json) {
1602
+ console.error(fail(note, "fix ~/.tot permissions, then re-run; no candidate ref was pushed"));
1603
+ console.error(` → ${diagnosticReference(diagnostic, env)}`);
1604
+ }
1605
+ emitJson(args, buildJsonResult({
1606
+ ok: false, ref, commit, changeId, error: note, diagnosticId: diagnostic.diagnosticId,
1607
+ }));
1528
1608
  return 1;
1529
1609
  }
1530
1610
 
@@ -1540,6 +1620,9 @@ export async function run(argv, ctx, {
1540
1620
  const res = await checkout(client, { tenant, tag, cloneDir: null, redact: redactUrl });
1541
1621
  return res.gitRemote || null;
1542
1622
  } catch (e) {
1623
+ recordDiagnostic(e, {
1624
+ command: verb, operation: "tenant_checkout", required: false, degraded: true,
1625
+ }, env);
1543
1626
  console.error(
1544
1627
  `~ couldn't mint a fresh push credential (${redactUrl(String(e?.message || e))}) — using the checkout's remote`,
1545
1628
  );
@@ -1558,8 +1641,14 @@ export async function run(argv, ctx, {
1558
1641
  errorClass: isForgeAuthError(e?.stderr || e?.message || e) ? "forge_auth" : "git_push_failed",
1559
1642
  });
1560
1643
  const msg = `push failed: ${redactUrl(String(e.stderr || e.message || e))}`;
1644
+ const diagnostic = recordDiagnostic(new Error(msg, { cause: e }), {
1645
+ command: verb, operation: "git_push", required: true, degraded: false,
1646
+ }, env);
1561
1647
  console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
1562
- emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
1648
+ if (!args.json) console.error(` → ${diagnosticReference(diagnostic, env)}`);
1649
+ emitJson(args, buildJsonResult({
1650
+ ok: false, ref, commit, changeId, error: msg, diagnosticId: diagnostic.diagnosticId,
1651
+ }));
1563
1652
  return 1;
1564
1653
  }
1565
1654
  if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
@@ -1568,18 +1657,26 @@ export async function run(argv, ctx, {
1568
1657
  // 2b + 3. open/update the PR-backed candidate, then report reconcile +
1569
1658
  // compliance + preview URL from the MCP — reusing the session established above.
1570
1659
  let progress = null;
1660
+ let candidate = null;
1661
+ let previewPrUrl = null;
1662
+ let operation = "client_switch";
1663
+ const candidateRequired = changedEntries.length > 0;
1571
1664
  try {
1572
1665
  // Bind the active tenant so preview_status/candidate_open read the right scope
1573
1666
  // (idempotent — checkoutTenant already switched when the fresh mint succeeded).
1574
1667
  try {
1575
1668
  await client.callTool("client_switch", { tenant });
1576
1669
  } catch (e) {
1577
- throw new CandidateRegistrationError(`Candidate not created: tenant scope could not be bound (${describeReadbackError(e)}).`);
1670
+ throw new CandidateRegistrationError(
1671
+ `Candidate not created: tenant scope could not be bound (${describeReadbackError(e)}).`,
1672
+ e,
1673
+ );
1578
1674
  }
1579
1675
 
1580
1676
  // 2b. Register the exact ref and asserted commit that git just pushed. No file
1581
1677
  // bytes cross MCP and the server does not reconstruct or replace these commits.
1582
- const candidate = await submitCandidate(client, {
1678
+ operation = "candidate_open";
1679
+ candidate = await submitCandidate(client, {
1583
1680
  repo, changeId, ref, headSha: commit, changeSummary,
1584
1681
  hasChanges: changedEntries.length > 0, quiet: args.json,
1585
1682
  });
@@ -1602,6 +1699,7 @@ export async function run(argv, ctx, {
1602
1699
  // never blocking the push that already landed. Gated on --skip-freshness via
1603
1700
  // baseDrift (0 when skipped).
1604
1701
  if (repo && baseDrift > 0 && candidate && !isTerminalCandidateState(candidate.state) && changeId) {
1702
+ operation = "candidate_refresh";
1605
1703
  const rebased = await runBornRebased(client, { repo, changeId, strategy: bornRebasedStrategy });
1606
1704
  if (rebased.ok) {
1607
1705
  for (const line of formatBornRebasedSuccess({ behind: baseDrift, strategy: rebased.strategy || bornRebasedStrategy, refreshedFiles: rebased.refreshedFiles })) {
@@ -1619,6 +1717,7 @@ export async function run(argv, ctx, {
1619
1717
  candidate: { ...candidate, prNumber: freshPr ?? candidate.prNumber },
1620
1718
  previewPrUrl: freshPrUrl,
1621
1719
  note: `born-rebased on the current base (${rebased.strategy || bornRebasedStrategy})`,
1720
+ ...invocationDegradedResult(),
1622
1721
  }));
1623
1722
  return 0;
1624
1723
  }
@@ -1639,7 +1738,7 @@ export async function run(argv, ctx, {
1639
1738
  // the server-minted `previewUrl` (formatShareableUrlBlock) that only shows
1640
1739
  // up once reconcile actually completes. Honest framing: it's printed as
1641
1740
  // "building", never as "ready".
1642
- const previewPrUrl =
1741
+ previewPrUrl =
1643
1742
  candidate && !isTerminalCandidateState(candidate.state) && typeof candidate.prNumber === "number"
1644
1743
  ? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, candidate.prNumber)
1645
1744
  : null;
@@ -1657,6 +1756,7 @@ export async function run(argv, ctx, {
1657
1756
  }
1658
1757
 
1659
1758
  let status;
1759
+ operation = "preview_status";
1660
1760
  if (args.noWait) {
1661
1761
  status = normalizePreviewStatus(await client.callTool("preview_status", { commit: statusSha }));
1662
1762
  } else {
@@ -1702,22 +1802,38 @@ export async function run(argv, ctx, {
1702
1802
  const noChanges = changedEntries.length === 0;
1703
1803
  reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit: statusSha, ref, verb, noChanges });
1704
1804
  const ok = previewSubmitSucceeded(status);
1705
- emitJson(args, buildJsonResult({ ok, ref, commit, changeId, candidate, status, previewPrUrl, noChanges }));
1805
+ const degradation = invocationDegradedResult();
1806
+ if (degradation.degraded && !args.json) {
1807
+ console.log(` ~ completed with degraded optional diagnostics — ${diagnosticReference({ diagnosticId: degradation.diagnosticId }, env)}`);
1808
+ }
1809
+ emitJson(args, buildJsonResult({
1810
+ ok, ref, commit, changeId, candidate, status,
1811
+ previewPrUrl, noChanges, ...degradation,
1812
+ }));
1706
1813
  return ok ? 0 : 1;
1707
1814
  } catch (e) {
1708
1815
  progress?.stop();
1709
- if (e instanceof CandidateRegistrationError) {
1710
- const note = `${e.message} Your preview push is in, but no review PR was created.`;
1711
- if (!args.json) console.error(fail(note, `re-run \`tot submit\`; retry will reuse ${ref}`));
1712
- emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
1713
- return 1;
1816
+ const outcome = candidateFailureOutcome({
1817
+ error: e, candidateRequired, candidate, operation, command: verb, env,
1818
+ ref, commit, changeId, previewPrUrl,
1819
+ });
1820
+ if (outcome.required) {
1821
+ if (!args.json) {
1822
+ const next = e instanceof CandidateRegistrationError
1823
+ ? `re-run \`tot ${verb}\`; retry will reuse ${ref}`
1824
+ : `fix the reported MCP failure, then re-run \`tot ${verb}\``;
1825
+ console.error(fail(outcome.note, next));
1826
+ console.error(` → ${diagnosticReference(outcome.diagnostic, env)}`);
1827
+ }
1828
+ emitJson(args, outcome.result);
1829
+ return outcome.exitCode;
1714
1830
  }
1715
- const note = e instanceof AuthUnavailableError
1716
- ? `Preview result unavailable: ${e.hint || "developer sign-in is required"}.`
1717
- : `Preview result unavailable: ${describeReadbackError(e)}.`;
1718
- if (!args.json) console.error(fail(note, `the candidate ref remains at ${ref}; fix the error and re-run`));
1719
- emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
1720
- return 1;
1831
+ if (!args.json) {
1832
+ console.log(` (${outcome.note})`);
1833
+ console.log(` ${diagnosticReference(outcome.diagnostic, env)}`);
1834
+ }
1835
+ emitJson(args, outcome.result);
1836
+ return outcome.exitCode;
1721
1837
  }
1722
1838
  }
1723
1839
 
@@ -32,6 +32,7 @@
32
32
  import { execFileSync } from "node:child_process";
33
33
  import { fail } from "../errors.mjs";
34
34
  import { ensureTokenlessRemote } from "../git-credential.mjs";
35
+ import { recordDiagnostic } from "../diagnostics.mjs";
35
36
 
36
37
  /** The protected branch `tot sync` fetches + merges from by default. */
37
38
  export const DEFAULT_SYNC_BRANCH = "preview";
@@ -173,6 +174,9 @@ export async function run(argv, ctx) {
173
174
  // missing repo, with nothing pointing back here.
174
175
  console.error(`~ could not repair this checkout's forge credentials: ${e?.message || e}`);
175
176
  console.error(" a fetch failure below is likely this, not a missing repo.");
177
+ recordDiagnostic(e, {
178
+ command: "sync", operation: "credential_helper_install", required: false, degraded: true,
179
+ });
176
180
  }
177
181
 
178
182
  // Refuse a dirty tree up front — a merge on top of uncommitted edits is how