@tokenoftrust/cli 1.4.0-rc.21 → 1.4.0-rc.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.4.0-rc.21",
3
+ "version": "1.4.0-rc.23",
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",
@@ -7,7 +7,7 @@
7
7
  * nothing here (backward-compatible with the stateless original). This file only
8
8
  * records a DIVERGENCE from that stable default:
9
9
  *
10
- * - `tot submit --new` forks a fresh candidate and remembers it here, so the
10
+ * - `tot submit --fork-candidate` forks a fresh candidate and remembers it here, so the
11
11
  * NEXT plain `tot submit` keeps updating the NEW PR (like pushing more commits
12
12
  * to a `gh pr` branch), not the old one; and
13
13
  * - a terminal-roll (the active candidate was merged/closed) records the fresh
@@ -123,7 +123,7 @@ export function clearActiveChangeId(filePath, { mcpUrl, repo, branch }) {
123
123
  /**
124
124
  * A fresh candidate handle forked from a stable base — `<baseId>-<suffix>`, still
125
125
  * matching candidate_open's `[a-z0-9._-]` handle grammar. The suffix defaults to
126
- * 6 random hex chars (so two `--new` runs never collide); tests inject a fixed
126
+ * 6 random hex chars (so two `--fork-candidate` runs never collide); tests inject a fixed
127
127
  * suffix. Pure given `suffix`.
128
128
  */
129
129
  export function mintFreshChangeId(baseId, suffix = randomBytes(3).toString("hex")) {
@@ -48,6 +48,10 @@ import { establishSession, AuthUnavailableError } from "../auth.mjs";
48
48
  // No-operator-secret path: mint a viewer session from the developer's OWN `tot`
49
49
  // login and integrate as themselves (server gates on their live ship-on-behalf grant).
50
50
  import { resolveViewerTransport } from "../viewer-session.mjs";
51
+ // C5 — the doctor PUSH: on a failed accept/integrate, auto-append the COMPACT
52
+ // "here's what's blocking you" summary over the SAME transport we just used, so a
53
+ // developer doesn't have to remember to run `tot preview doctor` themselves.
54
+ import { autoSurfaceDoctor } from "../merge-doctor-report.mjs";
51
55
 
52
56
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
53
57
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
@@ -531,6 +535,15 @@ export async function runIntegrate(
531
535
  authHeaders = viewer.authHeaders;
532
536
  }
533
537
 
538
+ // C5 — auto-surface the merge-doctor on a failed accept. Best-effort: fetches the
539
+ // hosted doctor over the SAME transport we just resolved and prints the COMPACT
540
+ // summary; never throws and never touches the exit code (it's called AFTER the
541
+ // outcome is decided, purely to append diagnostic context to where the failure
542
+ // already surfaced). A clean/unreachable doctor prints nothing.
543
+ const surfaceDoctor = async () => {
544
+ for (const line of await autoSurfaceDoctor({ base, authHeaders, fetchImpl })) console.error(line);
545
+ };
546
+
534
547
  // 2.5. MERGEABLE PREFLIGHT — read the candidate's forge verdict from b07's queue
535
548
  // (`GET /api/changes`, SAME transport) so a doomed accept never round-trips to
536
549
  // the forge. Best-effort: on any listing gap it's `known:false` → proceed as
@@ -625,10 +638,13 @@ export async function runIntegrate(
625
638
  for (const line of notMergeableOfferLines(tenant, { pr: curPr, changeId: curChangeId })) {
626
639
  console.log(line);
627
640
  }
641
+ await surfaceDoctor();
628
642
  return 1;
629
643
  }
630
644
 
631
- return reportIntegrated(result, { tenant, label: /** @type {string} */ (label) });
645
+ const code = reportIntegrated(result, { tenant, label: /** @type {string} */ (label) });
646
+ if (code !== 0) await surfaceDoctor();
647
+ return code;
632
648
  }
633
649
 
634
650
  /** The mergeable PREFLIGHT read (best-effort). See readCandidateVerdict. */
@@ -7,7 +7,7 @@
7
7
  * tot pr close <N|id> close (reject) a candidate without merging
8
8
  *
9
9
  * A candidate PR is the reviewable unit `tot submit` creates. By default a
10
- * re-submit UPDATES your open candidate; `tot submit --new` forks another. This
10
+ * re-submit UPDATES your open candidate; `tot submit --fork-candidate` forks another. This
11
11
  * command fills the gap the raw submit loop left — a first-party way to list your
12
12
  * open candidates and to close one (candidate close was otherwise gated to
13
13
  * version-control apps only).
@@ -71,7 +71,7 @@ const USAGE = `tot pr — see and manage candidate PRs
71
71
 
72
72
  DEVELOPER (default) — run inside your OWN store checkout: lists/manages the
73
73
  candidates \`tot submit\` opens. A re-submit updates your open one by default;
74
- \`tot submit --new\` forks another.
74
+ \`tot submit --fork-candidate\` forks another.
75
75
 
76
76
  OPERATOR — \`tot pr list --tenant <appDomain>\` lists ANY tenant's open candidate
77
77
  queue WITHOUT a checkout (the read-only companion to \`tot ship --pr <N> --tenant\`).
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `tot preview doctor --tenant <t>` — the merge self-serve DIAGNOSIS in the terminal.
3
+ *
4
+ * Calls the hosted merge-doctor seam `GET /<tenant>/api/preview/merge-doctor` (unit
5
+ * A2) — which composes the SAME two server-side reads the admin Publish tab builds
6
+ * (`GET /api/changes` + `GET /api/changes/branches`) through the PURE `analyzeMerge`
7
+ * taxonomy — and prints its `{ scopeKnown, verdict, findings, counts }` as the compact,
8
+ * ordered (blocker → warn → info) report, each finding carrying who-owns-it and its
9
+ * remedy/action. It composes NO per-PR forge calls of its own: one GET, one answer.
10
+ *
11
+ * READ-ONLY — it mutates nothing, so there is NO plan/confirm gate (unlike `tot
12
+ * accept` / `tot preview build`). It just answers "what's blocking my merges, and
13
+ * what do I do about it?" in seconds.
14
+ *
15
+ * AUTH — the endpoint is session-gated exactly like `GET /api/changes`, so this
16
+ * reaches it by EITHER of the two transports `tot accept` already speaks:
17
+ * - OPERATOR SECRET (operators / headless CI): Bearer + `X-Tot-Owner` on the
18
+ * generic storefront origin (from PREVIEW_RECONCILE_SECRET / GRANTS_ADMIN_SECRET
19
+ * / TOT_OPERATOR_SECRET, or `--secret`).
20
+ * - VIEWER SESSION (an invited developer with no secret): mint a `tot_session`
21
+ * from their own `tot login` on the tenant's own host (`resolveViewerTransport`).
22
+ * A caller who cannot see the accept queue cannot get a diagnosis of it — an auth
23
+ * refusal (401/403) is relayed as a clean house-style failure, not a wrapped verdict.
24
+ *
25
+ * EXIT CODE — mirrors the `scripts/tenant/gitea-merge-doctor.mjs` driver: a report
26
+ * with any blocker exits 1 (so a CI gate fails on a blocked merge), otherwise 0.
27
+ *
28
+ * The rendering helpers are the SHARED port in `../merge-doctor-report.mjs` (a
29
+ * faithful PORT of the analyzer's own `formatReport`/`attentionBanner`/
30
+ * `supportContext` — mergeDoctor.ts §render / the gitea-merge-doctor.mjs mirror);
31
+ * this package is the dependency-free published `@tokenoftrust/cli`, so it cannot
32
+ * import the app/scripts source, and the port is kept in sync with that taxonomy by
33
+ * hand. They live in their own module so `tot accept` / `tot ship` can reuse the same
34
+ * fetch + compact render (C5) without a cycle back through this command's transports.
35
+ *
36
+ * Dependency-free (global fetch + the shared viewer/operator transports).
37
+ */
38
+ import { fail } from "../errors.mjs";
39
+ import { resolveOperatorSecret } from "./ship.mjs";
40
+ import { resolveViewerTransport } from "../viewer-session.mjs";
41
+ import {
42
+ DOCTOR_PATH,
43
+ normalizeAnalysis,
44
+ attentionBanner,
45
+ supportContext,
46
+ formatReport,
47
+ } from "../merge-doctor-report.mjs";
48
+
49
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
50
+
51
+ const USAGE = `tot preview doctor — diagnose what's blocking this tenant's merges
52
+
53
+ tot preview doctor diagnose the current checkout's tenant
54
+ tot preview doctor --tenant <t> diagnose <t> (works without a checkout)
55
+ tot preview doctor --json emit the raw { scopeKnown, verdict, findings, counts }
56
+
57
+ Calls the hosted merge-doctor (GET /api/preview/merge-doctor), which composes the
58
+ SAME reads the admin Publish tab builds and runs them through the shared analyzer,
59
+ and prints the ordered report: blockers first, then warnings, then info — each
60
+ finding names who resolves it (you / on-us / housekeeping) and the exact remedy.
61
+ Read-only: it mutates nothing and needs no confirmation.
62
+
63
+ Exit code: 1 if the report has any blocker (so it can gate CI), else 0.
64
+
65
+ Options:
66
+ --tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
67
+ current checkout's tenant when run inside one.
68
+ --url <origin> Storefront origin for the operator-secret path
69
+ (default: env TOT_STOREFRONT_URL or ${DEFAULT_STOREFRONT_URL}).
70
+ --secret <s> Operator secret (prefer the env vars below).
71
+ --json Print the raw analysis JSON instead of the report.
72
+ --help, -h Show this help.
73
+
74
+ Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
75
+ GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret). Without one, an
76
+ invited developer's own \`tot login\` session is used.`;
77
+
78
+ /** Parse `tot preview doctor` argv. Pure — unit-testable. */
79
+ export function parsePreviewDoctorArgs(argv) {
80
+ const a = { tenant: null, url: null, secret: null, json: false, help: false };
81
+ for (let i = 0; i < argv.length; i++) {
82
+ const t = argv[i];
83
+ if (t === "--tenant") a.tenant = argv[++i];
84
+ else if (t === "--url") a.url = argv[++i];
85
+ else if (t === "--secret") a.secret = argv[++i];
86
+ else if (t === "--json") a.json = true;
87
+ else if (t === "--help" || t === "-h") a.help = true;
88
+ }
89
+ return a;
90
+ }
91
+
92
+ // The render/normalize helpers (normalizeAnalysis / attentionBanner /
93
+ // supportContext / formatReport) are the shared port in ../merge-doctor-report.mjs,
94
+ // imported at the top of this file.
95
+
96
+ // ── run ────────────────────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Fetch + render the merge-doctor report for one tenant. Resolves the SAME two
100
+ * transports `tot accept` uses (operator secret → generic origin + X-Tot-Owner;
101
+ * else viewer session → tenant host + cookie), GETs the hosted seam, and prints the
102
+ * ordered report (or raw JSON). `fetch`/`resolveViewerTransport` are injected so it
103
+ * is unit-tested with no network. Returns the process exit code (blocker → 1).
104
+ *
105
+ * @param {{ tenant:string, secret:string, storefrontUrl?:string|null,
106
+ * json?:boolean, env?:NodeJS.ProcessEnv }} params
107
+ * @param {{ fetch?:typeof fetch, resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
108
+ * @returns {Promise<number>}
109
+ */
110
+ export async function runDoctor(
111
+ { tenant, secret, storefrontUrl = null, json = false, env = process.env },
112
+ deps = {},
113
+ ) {
114
+ const fetchImpl = deps.fetch || globalThis.fetch;
115
+ const resolveViewer = deps.resolveViewerTransport || resolveViewerTransport;
116
+
117
+ // Resolve the transport — operator secret preferred, else the developer's own
118
+ // viewer session on the tenant host.
119
+ let base;
120
+ let authHeaders;
121
+ if (secret) {
122
+ base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
123
+ authHeaders = {
124
+ authorization: `Bearer ${secret}`,
125
+ "x-tot-owner": tenant,
126
+ "x-tot-capability": "ship-on-behalf",
127
+ };
128
+ } else {
129
+ const viewer = await resolveViewer({ tenant, env, fetchImpl });
130
+ if (!viewer.ok) {
131
+ console.error(fail(viewer.message, viewer.hint));
132
+ return 2;
133
+ }
134
+ base = viewer.base;
135
+ authHeaders = viewer.authHeaders;
136
+ }
137
+
138
+ let res;
139
+ try {
140
+ res = await fetchImpl(`${base}${DOCTOR_PATH}`, { method: "GET", headers: authHeaders });
141
+ } catch (e) {
142
+ console.error(
143
+ fail(
144
+ `couldn't reach the merge-doctor at ${base}: ${String(e?.message || e)}`,
145
+ "check --url / your network, then re-run",
146
+ ),
147
+ );
148
+ return 1;
149
+ }
150
+
151
+ let data = {};
152
+ try {
153
+ data = await res.json();
154
+ } catch {
155
+ /* non-JSON / empty body */
156
+ }
157
+
158
+ // Session gating: the endpoint relays the queue read's auth refusal verbatim, so a
159
+ // caller who cannot see the accept queue cannot diagnose it.
160
+ if (res.status === 401 || res.status === 403) {
161
+ console.error(
162
+ fail(
163
+ `not authorized to diagnose ${tenant}'s merges: ${data?.error || `HTTP ${res.status}`}`,
164
+ "you need a live ship-on-behalf grant on this tenant (ask the store owner), or an operator secret authorised for it — then re-run",
165
+ ),
166
+ );
167
+ return 2;
168
+ }
169
+
170
+ if (!res.ok) {
171
+ console.error(
172
+ fail(
173
+ `the merge-doctor did not answer for ${tenant}: ${data?.error || `HTTP ${res.status}`}`,
174
+ "check --tenant / --url, then re-run",
175
+ ),
176
+ );
177
+ return 1;
178
+ }
179
+
180
+ const analysis = normalizeAnalysis(data);
181
+
182
+ if (json) {
183
+ console.log(JSON.stringify(analysis, null, 2));
184
+ } else {
185
+ console.log(formatReport(analysis));
186
+ }
187
+
188
+ // Mirror the scripts driver: any blocker fails the run so a CI gate catches it.
189
+ return analysis.counts.blocker > 0 ? 1 : 0;
190
+ }
191
+
192
+ /**
193
+ * @param {string[]} argv
194
+ * @param {any} ctx — detected CLI context (ctx.tenant when in a checkout)
195
+ */
196
+ export async function run(argv, ctx) {
197
+ const env = process.env;
198
+ const args = parsePreviewDoctorArgs(argv);
199
+ if (args.help) {
200
+ console.log(USAGE);
201
+ return 0;
202
+ }
203
+
204
+ const tenant = (args.tenant || ctx?.tenant || "").trim();
205
+ if (!tenant) {
206
+ console.error(
207
+ fail(
208
+ "no target tenant.",
209
+ "pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), or run inside a store checkout.",
210
+ ),
211
+ );
212
+ return 2;
213
+ }
214
+
215
+ const storefrontUrl =
216
+ args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
217
+
218
+ return await runDoctor({
219
+ tenant,
220
+ secret: resolveOperatorSecret(args.secret, env),
221
+ storefrontUrl,
222
+ json: args.json,
223
+ env,
224
+ });
225
+ }
@@ -59,6 +59,14 @@ export async function run(argv, ctx, { alias = null } = {}) {
59
59
  return runBuild(argv.slice(1), ctx);
60
60
  }
61
61
 
62
+ // `tot preview doctor …` is the read-only merge DIAGNOSIS subcommand (unit C1) —
63
+ // a distinct verb from the developer preview flow, dispatched before the submit-
64
+ // flow arg parse (like `build`). Only the first-class `tot preview` carries it.
65
+ if (!alias && argv[0] === "doctor") {
66
+ const { run: runDoctor } = await import("./preview-doctor.mjs");
67
+ return runDoctor(argv.slice(1), ctx);
68
+ }
69
+
62
70
  const verb = alias || "preview";
63
71
  const args = parseArgs(argv);
64
72
 
@@ -48,6 +48,11 @@ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
48
48
  import { startProgress } from "../progress.mjs";
49
49
  import { openBrowser } from "../open.mjs";
50
50
  import { emitActivity } from "../activity.mjs";
51
+ // C5 — the doctor PUSH: on a refused/failed ship (most tellingly "not green"),
52
+ // auto-append the COMPACT summary over the SAME operator transport, so the operator
53
+ // sees WHICH changes are keeping the aggregate from going green without a separate
54
+ // `tot preview doctor` run.
55
+ import { autoSurfaceDoctor } from "../merge-doctor-report.mjs";
51
56
 
52
57
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
53
58
 
@@ -327,6 +332,14 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
327
332
  "x-tot-capability": "ship-on-behalf",
328
333
  };
329
334
 
335
+ // C5 — auto-surface the merge-doctor on a refused/failed ship. Best-effort:
336
+ // fetches the hosted doctor over the SAME operator transport and prints the COMPACT
337
+ // summary; never throws and never touches the exit code. A clean/unreachable doctor
338
+ // prints nothing — so this only ever ADDS the "here's what's not green" context.
339
+ const surfaceDoctor = async () => {
340
+ for (const line of await autoSurfaceDoctor({ base, authHeaders, fetchImpl })) console.error(line);
341
+ };
342
+
330
343
  // 1. GET the read-only plan: the pinned sha + artifact digest + included PRs +
331
344
  // rollback target + paywall verdict. Zero side effects.
332
345
  let planRes;
@@ -356,6 +369,9 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
356
369
  const plan = normalizeShipPlan(planData);
357
370
  if (!plan.ok) {
358
371
  console.error(fail(plan.message, refusalNextStep(plan.reason)));
372
+ // A not-green / no-passed-run refusal is exactly what the doctor diagnoses —
373
+ // name the changes keeping the aggregate red right here.
374
+ await surfaceDoctor();
359
375
  return 1;
360
376
  }
361
377
 
@@ -433,7 +449,9 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
433
449
  payload: { args: { command: "ship", subcommand: "ship.publish" } },
434
450
  });
435
451
 
436
- return reportShipResult(shipResult, { tenant, liveUrl, noOpen, openUrl: deps.openUrl });
452
+ const code = reportShipResult(shipResult, { tenant, liveUrl, noOpen, openUrl: deps.openUrl });
453
+ if (code !== 0) await surfaceDoctor();
454
+ return code;
437
455
  }
438
456
 
439
457
  /**
@@ -47,6 +47,10 @@ import { setTimeout as delay } from "node:timers/promises";
47
47
  import { createMcpClient } from "../mcp.mjs";
48
48
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
49
49
  import { checkoutTenant } from "./clone.mjs";
50
+ // Reuse the operator side's `candidate_refresh` result normaliser (unit c3 — the
51
+ // born-rebased submit rebuilds a candidate onto the current base with the SAME
52
+ // engine `tot accept --refresh` uses, so the two read its result identically).
53
+ import { normalizeRefreshResult } from "./accept.mjs";
50
54
  import { validateTenant, ERROR } from "../validate.mjs";
51
55
  import { openBrowser } from "../open.mjs";
52
56
  import { startProgress } from "../progress.mjs";
@@ -107,8 +111,8 @@ export function parseArgs(argv) {
107
111
  // `ref: null` — an explicit `--ref` always wins; otherwise the push target is
108
112
  // derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
109
113
  // never a fixed shared default.
110
- /** @type {{ mcp: string|null, identity: string|null, ref: string|null, skipValidate: boolean, skipFreshness: boolean, noWait: boolean, watch: boolean, noOpen: boolean, noCommit: boolean, message: string|null, summary: string|null, summaryFile: string|null, json: boolean, new: boolean, help: boolean }} */
111
- const a = { mcp: null, identity: null, ref: null, skipValidate: false, skipFreshness: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, summaryFile: null, json: false, new: false, help: false };
114
+ /** @type {{ mcp: string|null, identity: string|null, ref: string|null, skipValidate: boolean, skipFreshness: boolean, noWait: boolean, watch: boolean, noOpen: boolean, noCommit: boolean, message: string|null, summary: string|null, summaryFile: string|null, strategy: string|null, json: boolean, forkCandidate: boolean, help: boolean }} */
115
+ const a = { mcp: null, identity: null, ref: null, skipValidate: false, skipFreshness: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, summaryFile: null, strategy: null, json: false, forkCandidate: false, help: false };
112
116
  for (let i = 0; i < argv.length; i++) {
113
117
  const t = argv[i];
114
118
  if (t === "--mcp") a.mcp = argv[++i];
@@ -117,6 +121,7 @@ export function parseArgs(argv) {
117
121
  else if (t === "-m" || t === "--message") a.message = argv[++i];
118
122
  else if (t === "--summary") a.summary = argv[++i];
119
123
  else if (t === "--summary-file") a.summaryFile = argv[++i];
124
+ else if (t === "--strategy") a.strategy = argv[++i];
120
125
  else if (t === "--json") a.json = true;
121
126
  else if (t === "--skip-validate") a.skipValidate = true;
122
127
  else if (t === "--skip-freshness") a.skipFreshness = true;
@@ -124,7 +129,7 @@ export function parseArgs(argv) {
124
129
  else if (t === "--no-wait") a.noWait = true;
125
130
  else if (t === "--watch") a.watch = true;
126
131
  else if (t === "--no-open") a.noOpen = true;
127
- else if (t === "--new") a.new = true;
132
+ else if (t === "--fork-candidate") a.forkCandidate = true;
128
133
  else if (t === "--help" || t === "-h") a.help = true;
129
134
  }
130
135
  return a;
@@ -140,11 +145,20 @@ export function renderUsage(verb = "preview") {
140
145
  return `tot ${verb} — submit your store for preview
141
146
 
142
147
  tot ${verb} validate → push the preview ref → stream the result
143
- tot ${verb} --new open a NEW candidate PR instead of updating your open one
148
+ tot ${verb} --fork-candidate open a SECOND, independently-tracked candidate (a
149
+ parallel dev path). Rarely needed — a fresh git
150
+ branch already gets its own candidate automatically
151
+ (git checkout is the PR switcher); use this only to
152
+ run two candidates from ONE branch.
144
153
  tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
145
154
  tot ${verb} --skip-validate push without the local lint (not recommended)
146
- tot ${verb} --skip-freshness skip the stale-base check (not recommended may build a
147
- candidate rooted in an already-superseded base)
155
+ tot ${verb} --skip-freshness skip the stale-base check AND the born-rebased rebuild
156
+ (not recommended — may build a candidate rooted in an
157
+ already-superseded base)
158
+ tot ${verb} --strategy <s> how the born-rebased rebuild resolves a file changed on
159
+ BOTH sides when the base has moved: "merge" (real 3-way
160
+ merge, surfaces a resolve card on a genuine overlap — the
161
+ default), "ours" (keep yours), "theirs" (keep the store's)
148
162
  tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
149
163
  tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
150
164
  tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
@@ -162,13 +176,14 @@ export function renderUsage(verb = "preview") {
162
176
  tot ${verb} --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
163
177
 
164
178
  By default a re-run UPDATES your open candidate PR (like pushing more commits
165
- to a GitHub PR), rather than opening a new one each time. Use --new to fork a
166
- fresh candidate PR; the next plain \`tot ${verb}\` then updates THAT one. Manage your
167
- open candidates with \`tot pr\` (list / view / close). If your candidate was already
179
+ to a GitHub PR), rather than opening a new one each time. Manage your open
180
+ candidates with \`tot pr\` (list / view / close). If your candidate was already
168
181
  merged or closed, a re-run automatically opens a fresh one.
169
182
 
170
- Working on one thing at a time? You don't need --new, a git branch, or any
171
- branch management at all just keep editing and re-running \`tot ${verb}\`.
183
+ Starting a separate change? \`git checkout -b <branch>\` a fresh branch gets its
184
+ own candidate automatically, and \`git checkout\` back and forth is how you switch
185
+ between them. --fork-candidate is an escape hatch for the rarer case of wanting a
186
+ SECOND candidate off the SAME branch; reach for a branch first.
172
187
 
173
188
  Once a preview reconciles cleanly, \`tot ship\` promotes it live.
174
189
 
@@ -350,7 +365,7 @@ export const FRESHNESS_BASE_BRANCH = "preview";
350
365
  * Detect a STALE local view of the base branch before minting a candidate —
351
366
  * the live-repeat incident this guards against: the checkout's `origin/preview`
352
367
  * tracking ref was stale (recorded before a just-merged PR moved it), so a
353
- * fresh `tot preview --new` built a candidate rooted in the OLD tip and got an
368
+ * fresh `tot preview --fork-candidate` built a candidate rooted in the OLD tip and got an
354
369
  * instant, entirely avoidable "not mergeable" the moment it was compared
355
370
  * against the real, already-advanced `preview`.
356
371
  *
@@ -386,6 +401,166 @@ export function detectStaleBase(git, branch) {
386
401
  return remoteSha;
387
402
  }
388
403
 
404
+ /**
405
+ * How many commits the base branch has advanced since this candidate forked off it
406
+ * (unit c2 — the shift-left base-drift warning). Conflicts in this loop are BASE
407
+ * DRIFT: a candidate branches off `<base>`, sits while OTHER candidates advance
408
+ * `<base>`, then settles mergeable=false at Accept time. This measures that drift
409
+ * cheaply and LOCALLY — the merge-base (fork point) of HEAD and the base's
410
+ * remote-tracking tip, then how many commits separate that fork point from the tip.
411
+ *
412
+ * PURE LOCAL git — reads `refs/remotes/origin/<branch>` (the last-fetched tip),
413
+ * never the network; it complements detectStaleBase (which confirms that tracking
414
+ * ref is itself current). Returns 0 — never a false warning — when there's nothing
415
+ * to compare against or the drift can't be positively determined: no base tracking
416
+ * ref yet, unrelated histories / no merge-base, HEAD already contains the tip, or
417
+ * any git failure. Non-blocking by contract: the caller warns on a positive count
418
+ * but always proceeds. Pure git I/O via the injected runner — unit-tested.
419
+ * @param {(cargs:string[])=>string} git
420
+ * @param {string} branch
421
+ * @returns {number}
422
+ */
423
+ export function baseCommitsBehind(git, branch) {
424
+ const baseTip = `refs/remotes/origin/${branch}`;
425
+ let tip = "";
426
+ try {
427
+ tip = git(["rev-parse", "-q", "--verify", baseTip]).trim();
428
+ } catch {
429
+ return 0; // no local tracking ref for the base — nothing to compare against
430
+ }
431
+ if (!tip) return 0;
432
+ let mergeBase = "";
433
+ try {
434
+ mergeBase = git(["merge-base", "HEAD", baseTip]).trim();
435
+ } catch {
436
+ return 0; // unrelated histories / no HEAD — nothing meaningful to count
437
+ }
438
+ if (!mergeBase || mergeBase === tip) return 0; // HEAD already contains the base tip
439
+ try {
440
+ const n = parseInt(git(["rev-list", "--count", `${mergeBase}..${baseTip}`]).trim(), 10);
441
+ return Number.isFinite(n) && n > 0 ? n : 0;
442
+ } catch {
443
+ return 0;
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Resolve the git ref to diff HEAD against for the candidate's file patch (and its
449
+ * summary). The candidate PR is `preview + your file changes`, and `candidate_open`
450
+ * SERVER-CUTS the candidate branch back to the current preview tip and re-applies the
451
+ * patch — so the patch MUST be the FULL delta of your branch vs its fork point off
452
+ * `preview`, never "what changed since my last candidate push". Diffing against your
453
+ * own candidate tracking ref is what silently produced an empty patch (and the wrong
454
+ * "no file changes" skip, so no PR opened) after a push that landed but failed to open
455
+ * its PR: that ref already equals HEAD, so the since-last-push delta is empty even
456
+ * though the change vs preview is 19 files. Prefer the merge-base (fork point) with
457
+ * `origin/<baseBranch>`; fall back to the candidate tracking ref, then HEAD~1, then ""
458
+ * (single-commit `git show`) when no base ref resolves. Pure git I/O via the injected
459
+ * runner — unit-tested.
460
+ * @param {(cargs:string[])=>string} git
461
+ * @param {string} ref the candidate ref (e.g. "candidate/local-abc")
462
+ * @param {string} [baseBranch] the preview base branch (default FRESHNESS_BASE_BRANCH)
463
+ * @returns {string}
464
+ */
465
+ export function resolvePatchBase(git, ref, baseBranch = FRESHNESS_BASE_BRANCH) {
466
+ const verify = (r) => {
467
+ try { return git(["rev-parse", "--verify", "--quiet", r]).trim(); } catch { return ""; }
468
+ };
469
+ const previewRef = `refs/remotes/origin/${baseBranch}`;
470
+ if (verify(previewRef)) {
471
+ try {
472
+ const forkPoint = git(["merge-base", "HEAD", previewRef]).trim();
473
+ if (forkPoint) return forkPoint;
474
+ } catch { /* unrelated histories — fall through */ }
475
+ }
476
+ const trackingRef = `refs/remotes/origin/${ref}`;
477
+ if (verify(trackingRef)) return trackingRef;
478
+ if (verify("HEAD~1")) return "HEAD~1";
479
+ return "";
480
+ }
481
+
482
+ // ─── born-rebased at submit (unit c3 — shift-left prevention #2) ─────────────────
483
+
484
+ /** The three strategies the born-rebased rebuild accepts, mirroring `tot accept
485
+ * --refresh` (accept.mjs) and the admin one-click resolver: `merge` (real 3-way,
486
+ * refuses on a genuine overlap), `ours` (keep yours), `theirs` (keep the store's).
487
+ * `merge` is the DEFAULT — auto-rebuild on pure drift, name the conflict on a real
488
+ * same-line overlap, never silently clobber a side. */
489
+ export const BORN_REBASED_STRATEGIES = ["ours", "theirs", "merge"];
490
+ export const DEFAULT_BORN_REBASED_STRATEGY = "merge";
491
+
492
+ /**
493
+ * Run `candidate_refresh` (the born-rebased rebuild, unit c3) over the ALREADY
494
+ * established MCP client — the candidate was just opened by candidate_open (step 2b)
495
+ * and the tenant scope is already bound, so this reuses that session rather than
496
+ * establishing its own (accept.mjs's runRefresh is the operator entry that does the
497
+ * sign-in; here the submit flow already holds the session). Rebuilds the candidate
498
+ * from the CURRENT base tip and re-applies its file changes under `strategy`. Returns
499
+ * the normalized result (status:"committed" is the only success). Best-effort: any
500
+ * throw — an owner-gated denial (candidate_refresh is app-owner gated), an older MCP
501
+ * without the tool, a transient failure — normalizes to a non-ok error result the
502
+ * caller submits-as-is on, NEVER blocking the push that already landed.
503
+ * @param {{callTool:Function}} client
504
+ * @param {{ repo: string, changeId: string, strategy: string }} opts
505
+ * @returns {Promise<ReturnType<typeof normalizeRefreshResult>>}
506
+ */
507
+ export async function runBornRebased(client, { repo, changeId, strategy }) {
508
+ try {
509
+ const raw = await client.callTool("candidate_refresh", { repo, changeId, strategy });
510
+ return normalizeRefreshResult(raw);
511
+ } catch (e) {
512
+ return { ...normalizeRefreshResult(null), status: "error", message: String(e?.message || e) };
513
+ }
514
+ }
515
+
516
+ /**
517
+ * The born-rebased SUCCESS lines (unit c3) — printed when the candidate rebuilt
518
+ * cleanly onto the current base tip, so it enters the queue already mergeable rather
519
+ * than settling not-mergeable at Accept time. `behind` is the base-drift count that
520
+ * triggered the rebuild. Pure — unit-tested.
521
+ * @param {{ behind?: number, strategy?: string, refreshedFiles?: string[] }} input
522
+ * @returns {string[]}
523
+ */
524
+ export function formatBornRebasedSuccess({ behind = 0, strategy = DEFAULT_BORN_REBASED_STRATEGY, refreshedFiles = [] } = {}) {
525
+ const drift = behind > 0 ? `${behind} commit${behind === 1 ? "" : "s"}` : "since you forked";
526
+ const lines = [
527
+ `\n ✓ rebuilt fresh on the current store (base had advanced ${drift}, strategy ${strategy}) — your candidate enters the queue already mergeable.`,
528
+ ];
529
+ if (refreshedFiles.length) lines.push(` reapplied: ${refreshedFiles.join(", ")}`);
530
+ return lines;
531
+ }
532
+
533
+ /**
534
+ * The born-rebased RESOLVE CARD (unit c3) — printed when the default `merge` rebuild
535
+ * hits a GENUINE same-line overlap with the base (not mere drift): the biggest single
536
+ * shift-left win is surfacing that conflict HERE, at submit, instead of letting it
537
+ * land as a stuck queue row someone discovers at Accept time. Names the diverged files
538
+ * and offers the two one-command resolutions (keep-mine / keep-store's) — `merge` is
539
+ * the default that just failed, so it isn't re-offered. Matches how the rest of this
540
+ * file reports next-steps (a `✗` headline + concrete `tot <verb> …` commands), never a
541
+ * raw git rebase instruction. Pure — unit-tested. `verb` brands the copy with whatever
542
+ * the developer typed.
543
+ * @param {{ unresolved?: string[] }} rr
544
+ * @param {string} [verb]
545
+ * @returns {string[]}
546
+ */
547
+ export function formatBornRebasedConflict({ unresolved = [] } = {}, verb = "preview") {
548
+ const lines = [
549
+ `\n ✗ your change conflicts with the current store on the same lines — it can't be auto-rebased.`,
550
+ ];
551
+ if (unresolved.length) {
552
+ lines.push(` These files changed on both sides since you forked and need your call:`);
553
+ for (const f of unresolved) lines.push(` - ${f}`);
554
+ }
555
+ lines.push(
556
+ ` Resolve it in one command — choose which side wins on those files:`,
557
+ ` tot ${verb} --strategy=ours keep YOURS on any clash`,
558
+ ` tot ${verb} --strategy=theirs keep the STORE's on any clash`,
559
+ ` Your push is in; the candidate is submitted but stays not-mergeable until you resolve it.`,
560
+ );
561
+ return lines;
562
+ }
563
+
389
564
  // ─── auto-commit the known content trees (unit u2) ───────────────────────────────
390
565
 
391
566
  /**
@@ -783,22 +958,23 @@ export function actorKeyFor(session) {
783
958
  * Which candidate this submit lands on (gh-pr-like) — decided UP FRONT, before any
784
959
  * network call, because it also determines the isolated git ref we push to
785
960
  * (resolvePushRef, below): a re-submit updates the SAME candidate/ref by default;
786
- * `--new` forks a fresh one.
787
- * --new → fork a FRESH candidate id;
788
- * otherwise → the remembered active candidate (from a prior --new / terminal
789
- * roll), else the STABLE per-dev-per-tenant(-per-branch) default.
961
+ * `--fork-candidate` forks a fresh one.
962
+ * forkCandidate → fork a FRESH candidate id;
963
+ * otherwise → the remembered active candidate (from a prior --fork-candidate /
964
+ * terminal roll), else the STABLE per-dev-per-tenant(-per-branch)
965
+ * default.
790
966
  * `persist` reports whether the choice diverges from the stable default, so the
791
967
  * caller knows whether to remember it as the new active pointer. `mint` is
792
968
  * injected (defaults to mintFreshChangeId) so this is pure/deterministic in tests.
793
969
  * Pure — unit-tested.
794
970
  * @param {{ tenant: string, actorKey: string, branch?: string|null, active?: string|null,
795
- * isNew?: boolean, mint?: (baseId: string) => string }} opts
971
+ * forkCandidate?: boolean, mint?: (baseId: string) => string }} opts
796
972
  * @returns {{ changeId: string, stableId: string, persist: boolean }}
797
973
  */
798
- export function chooseChangeId({ tenant, actorKey, branch = null, active = null, isNew = false, mint = mintFreshChangeId }) {
974
+ export function chooseChangeId({ tenant, actorKey, branch = null, active = null, forkCandidate = false, mint = mintFreshChangeId }) {
799
975
  const stableId = deriveChangeId(tenant, actorKey, branch);
800
- const changeId = isNew ? mint(stableId) : (active || stableId);
801
- const persist = isNew || (!!active && active !== stableId);
976
+ const changeId = forkCandidate ? mint(stableId) : (active || stableId);
977
+ const persist = forkCandidate || (!!active && active !== stableId);
802
978
  return { changeId, stableId, persist };
803
979
  }
804
980
 
@@ -1019,6 +1195,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1019
1195
  emitJson(args, buildJsonResult({ ok: false, error: msg }));
1020
1196
  return 2;
1021
1197
  }
1198
+ // --strategy (c3 born-rebased rebuild) — validate whenever given; defaults to
1199
+ // "merge" (auto-rebuild on drift, surface a resolve card on a genuine overlap).
1200
+ if (args.strategy != null && !BORN_REBASED_STRATEGIES.includes(args.strategy)) {
1201
+ const msg = `unknown --strategy "${args.strategy}"`;
1202
+ console.error(fail(msg, `use one of: ${BORN_REBASED_STRATEGIES.join(", ")} (default "${DEFAULT_BORN_REBASED_STRATEGY}")`));
1203
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1204
+ return 2;
1205
+ }
1206
+ const bornRebasedStrategy = args.strategy || DEFAULT_BORN_REBASED_STRATEGY;
1022
1207
  if (args.summaryFile) {
1023
1208
  let raw;
1024
1209
  try {
@@ -1054,6 +1239,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1054
1239
  /* best-effort — see above */
1055
1240
  }
1056
1241
 
1242
+ // How far the base has drifted since this candidate forked (unit c2), hoisted to the
1243
+ // whole flow: it drives BOTH the c2 pre-push warning (below) AND the c3 born-rebased
1244
+ // rebuild (after candidate_open). Stays 0 under --skip-freshness, so that flag opts
1245
+ // out of the rebuild too — consistent with opting out of the warning.
1246
+ let baseDrift = 0;
1247
+
1057
1248
  // Freshness preflight (unit u16) — BEFORE minting anything: is the checkout's
1058
1249
  // cached view of the base branch already behind the store? A candidate built
1059
1250
  // on a stale base is an instant, avoidable "not mergeable" the moment the
@@ -1074,6 +1265,24 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1074
1265
  emitJson(args, buildJsonResult({ ok: false, error: msg }));
1075
1266
  return 1;
1076
1267
  }
1268
+
1269
+ // Base-drift warning (unit c2) — a candidate rooted on a fork point the base has
1270
+ // since moved past is what settles mergeable=false at Accept time; catch it early,
1271
+ // right here, with a pure-LOCAL git read (no network — uses the base's
1272
+ // remote-tracking tip, which the stale-base preflight just above confirmed is
1273
+ // current). NON-BLOCKING: warn and submit anyway, so a work-in-progress preview
1274
+ // is never refused over base drift. Shares --skip-freshness with the preflight.
1275
+ try {
1276
+ baseDrift = baseCommitsBehind(git, FRESHNESS_BASE_BRANCH);
1277
+ } catch {
1278
+ baseDrift = 0; // never let the warning's OWN failure disturb the submit
1279
+ }
1280
+ if (baseDrift > 0) {
1281
+ console.error(`\n⚠ base is ${baseDrift} commit${baseDrift === 1 ? "" : "s"} behind — rebase before submit`);
1282
+ console.error(
1283
+ ` (\`${FRESHNESS_BASE_BRANCH}\` has advanced since your branch forked off it — \`tot sync\` to rebase, or --skip-freshness to silence)`,
1284
+ );
1285
+ }
1077
1286
  }
1078
1287
 
1079
1288
 
@@ -1181,12 +1390,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1181
1390
  };
1182
1391
  function buildSummaryAndPatch(ref) {
1183
1392
  const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
1184
- const trackingRef = `refs/remotes/origin/${ref}`;
1185
- const base = gitSafe(["rev-parse", "--verify", "--quiet", trackingRef]).trim()
1186
- ? trackingRef
1187
- : gitSafe(["rev-parse", "--verify", "--quiet", "HEAD~1"]).trim()
1188
- ? "HEAD~1"
1189
- : "";
1393
+ // Diff against the fork point off `preview` — the FULL branch delta — so the patch
1394
+ // is complete for candidate_open's server-cut (which resets the branch to preview
1395
+ // and re-applies this patch). NOT the candidate's own tracking ref: after a push
1396
+ // that landed but failed to open its PR, that ref equals HEAD → empty patch →
1397
+ // wrong "no file changes" → no PR. See resolvePatchBase.
1398
+ const base = resolvePatchBase(gitSafe, ref);
1190
1399
  const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
1191
1400
  const patchEntries = parseNameStatus(gitSafe(statusCmd));
1192
1401
  const files = patchEntries.map((e) => e.path);
@@ -1221,7 +1430,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1221
1430
  // still lands if that embedded token is live. actorKeyFor(null) degrades to the
1222
1431
  // generic "developer" key — still isolated PER BRANCH (never the shared ref),
1223
1432
  // just not per-developer until sign-in succeeds.
1224
- const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, isNew: args.new });
1433
+ const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, forkCandidate: args.forkCandidate });
1225
1434
  const ref = resolvePushRef({ ref: args.ref, changeId });
1226
1435
  const { changeSummary } = buildSummaryAndPatch(ref);
1227
1436
  console.error(`~ pushing ${short} → ${ref} (origin)`);
@@ -1264,11 +1473,11 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1264
1473
  // If it merged/closed we DROP it (and forget it on disk) so chooseChangeId falls
1265
1474
  // back to the stable id, rather than force-pushing onto a now-dead candidate branch
1266
1475
  // and opening a NEW PR that inherits a guaranteed conflict (the live incident this
1267
- // guards against). Skipped under --new (chooseChangeId ignores `active` there
1268
- // anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
1476
+ // guards against). Skipped under --fork-candidate (chooseChangeId ignores `active`
1477
+ // there anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
1269
1478
  // Needs the tenant scope bound for candidate_status to resolve — idempotent with
1270
1479
  // the later client_switch / the fresh-mint checkoutTenant.
1271
- if (active && repo && !args.new) {
1480
+ if (active && repo && !args.forkCandidate) {
1272
1481
  try {
1273
1482
  await client.callTool("client_switch", { tenant });
1274
1483
  } catch { /* scope bind is best-effort; candidateStateFor tolerates a miss */ }
@@ -1287,9 +1496,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1287
1496
  // Which candidate (and therefore which isolated ref, b03) this submit targets —
1288
1497
  // decided now, with a real session, so the SAME id backs both the raw git push
1289
1498
  // (right below) and the PR-backed candidate (step 2b): the two never point at
1290
- // different branches. See chooseChangeId's doc for the --new / active-pointer
1291
- // rules.
1292
- let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, isNew: args.new });
1499
+ // different branches. See chooseChangeId's doc for the --fork-candidate /
1500
+ // active-pointer rules.
1501
+ let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, forkCandidate: args.forkCandidate });
1293
1502
  const ref = resolvePushRef({ ref: args.ref, changeId });
1294
1503
  const { changeSummary, patchEntries } = buildSummaryAndPatch(ref);
1295
1504
 
@@ -1357,6 +1566,61 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1357
1566
  candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
1358
1567
  }
1359
1568
 
1569
+ // c3 — BORN-REBASED at submit (shift-left prevention #2). The candidate is open;
1570
+ // if the base has drifted (the SAME signal c2 warned on, pre-push) rebuild it from
1571
+ // the CURRENT base tip via candidate_refresh BEFORE finalizing, so it enters the
1572
+ // queue already fresh instead of settling not-mergeable at Accept time.
1573
+ // • clean rebuild → report it, hand back the fresh candidate's shareable URL,
1574
+ // and finish (the rebuilt PR has a NEW head; polling the old
1575
+ // local `commit` would read as never-dispatched, so we skip
1576
+ // the reconcile poll and say the fresh preview is building);
1577
+ // • genuine overlap (merge_failed) → surface the resolve card right here, then
1578
+ // fall through — candidate_refresh made NO changes on a real
1579
+ // conflict, so the candidate's head still matches `commit`
1580
+ // and the normal reconcile poll below is still valid;
1581
+ // • anything else (owner-gated denial, older MCP, error) → submit as-is.
1582
+ // Best-effort throughout: candidate_refresh is app-owner gated, so an ordinary
1583
+ // invited developer's session may be denied — that degrades to submitting as-is,
1584
+ // never blocking the push that already landed. Gated on --skip-freshness via
1585
+ // baseDrift (0 when skipped).
1586
+ if (repo && baseDrift > 0 && candidate && !isTerminalCandidateState(candidate.state) && changeId) {
1587
+ const rebased = await runBornRebased(client, { repo, changeId, strategy: bornRebasedStrategy });
1588
+ if (rebased.ok) {
1589
+ for (const line of formatBornRebasedSuccess({ behind: baseDrift, strategy: rebased.strategy || bornRebasedStrategy, refreshedFiles: rebased.refreshedFiles })) {
1590
+ if (!args.json) console.log(line);
1591
+ }
1592
+ const freshPr = typeof rebased.prNumber === "number" ? rebased.prNumber : candidate.prNumber;
1593
+ const freshPrUrl = typeof freshPr === "number"
1594
+ ? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, freshPr)
1595
+ : null;
1596
+ if (freshPrUrl && !args.json) {
1597
+ console.log(`\n ▸ Your fresh preview will appear at:\n ${freshPrUrl}\n (building on the current store — this link goes live once reconcile completes)`);
1598
+ }
1599
+ // The rebuild keeps the STABLE changeId, so the persisted active pointer stays
1600
+ // valid — record it (best-effort) exactly as the normal open path does below.
1601
+ if (persist && repo) {
1602
+ try {
1603
+ writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
1604
+ } catch { /* best-effort local hint */ }
1605
+ }
1606
+ emitJson(args, buildJsonResult({
1607
+ ok: true, ref, commit, changeId,
1608
+ candidate: { ...candidate, prNumber: freshPr ?? candidate.prNumber },
1609
+ previewPrUrl: freshPrUrl,
1610
+ note: `born-rebased on the current base (${rebased.strategy || bornRebasedStrategy})`,
1611
+ }));
1612
+ return 0;
1613
+ }
1614
+ if (rebased.status === "merge_failed") {
1615
+ for (const line of formatBornRebasedConflict({ unresolved: rebased.unresolved }, verb)) {
1616
+ if (!args.json) console.log(line);
1617
+ }
1618
+ // fall through to the normal poll — the candidate is unchanged.
1619
+ } else if (rebased.message && !args.json) {
1620
+ console.log(` ~ couldn't auto-rebase on the current base (${rebased.message}) — submitting as-is.`);
1621
+ }
1622
+ }
1623
+
1360
1624
  // Immediate shareable URL (Vercel-style: "the URL exists before the build
1361
1625
  // does"). A non-terminal candidate with a real PR number means a preview
1362
1626
  // WILL be built at a deterministic route — so hand the developer that link
@@ -0,0 +1,208 @@
1
+ /**
2
+ * merge-doctor-report — the SHARED, pure rendering + one-GET fetch of the hosted
3
+ * merge-doctor seam (`GET /<tenant>/api/preview/merge-doctor`, unit A2), so every
4
+ * CLI surface speaks ONE doctor taxonomy:
5
+ *
6
+ * - `tot preview doctor` (preview-doctor.mjs) — the full, ordered report on demand.
7
+ * - `tot accept` / `tot ship` (accept.mjs / ship.mjs) — the C5 PUSH: on a failed
8
+ * accept/integrate or a refused ship, auto-append the COMPACT summary right where
9
+ * the failure already surfaced, so a developer doesn't have to remember to run the
10
+ * doctor themselves.
11
+ *
12
+ * The render helpers here are a faithful PORT of the analyzer's own
13
+ * formatReport/attentionBanner/supportContext (mergeDoctor.ts §render / the
14
+ * scripts/tenant/gitea-merge-doctor.mjs mirror) — the published `@tokenoftrust/cli`
15
+ * is dependency-free and cannot import the app/scripts source, so this port is kept in
16
+ * sync with that taxonomy by hand.
17
+ *
18
+ * Dependency-light: `fail` + global fetch only. Imports NOTHING from the command
19
+ * modules (accept/ship/preview-doctor), so accept.mjs and ship.mjs can both depend on
20
+ * it without a cycle (preview-doctor.mjs already imports ship.mjs for the operator
21
+ * secret; routing the shared pieces through here keeps ship.mjs ⟷ preview-doctor.mjs
22
+ * acyclic).
23
+ */
24
+
25
+ /** The hosted seam path — one GET, one answer (composed server-side over the SAME
26
+ * reads the admin Publish tab builds, run through the shared analyzer). */
27
+ export const DOCTOR_PATH = "/api/preview/merge-doctor";
28
+
29
+ /** Who resolves a finding — the ticket-deflection axis. */
30
+ export const OWNERSHIP = { developer: "developer", platform: "platform", operator: "operator" };
31
+ export const SEV_GLYPH = { blocker: "✗", warn: "⚠", info: "•" };
32
+ export const OWN_TAG = { developer: "you", platform: "on-us", operator: "housekeeping" };
33
+
34
+ /**
35
+ * Defensively read the endpoint body into the analysis shape. The endpoint is a
36
+ * trust boundary, so a plausible field gap degrades rather than crashes. Pure.
37
+ * @param {any} data
38
+ * @returns {{ scopeKnown:boolean, verdict:string, findings:any[], counts:{blocker:number,warn:number,info:number} }}
39
+ */
40
+ export function normalizeAnalysis(data) {
41
+ const o = data && typeof data === "object" ? data : {};
42
+ const findings = Array.isArray(o.findings) ? o.findings : [];
43
+ const c = o.counts && typeof o.counts === "object" ? o.counts : {};
44
+ const counts = {
45
+ blocker: Number.isFinite(c.blocker) ? c.blocker : findings.filter((f) => f?.severity === "blocker").length,
46
+ warn: Number.isFinite(c.warn) ? c.warn : findings.filter((f) => f?.severity === "warn").length,
47
+ info: Number.isFinite(c.info) ? c.info : findings.filter((f) => f?.severity === "info").length,
48
+ };
49
+ return {
50
+ scopeKnown: o.scopeKnown === true,
51
+ verdict: typeof o.verdict === "string" ? o.verdict : "(no verdict returned)",
52
+ findings,
53
+ counts,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * The one-line banner shown at the top of the report — counts only
59
+ * DEVELOPER-actionable findings (what a person can self-serve); platform faults are
60
+ * narrated separately by {@link supportContext}. Pure.
61
+ */
62
+ export function attentionBanner(result) {
63
+ const dev = result.findings.filter((f) => f.ownership === OWNERSHIP.developer && f.severity !== "info");
64
+ const fixable = dev.filter((f) => f.action).length;
65
+ if (!dev.length) {
66
+ const platform = result.findings.filter((f) => f.ownership === OWNERSHIP.platform && f.severity === "blocker");
67
+ if (platform.length) return `${platform.length} issue(s) are on us — Retry, then Report to support if they persist.`;
68
+ return "Nothing needs your attention.";
69
+ }
70
+ const conflicts = dev.filter((f) => f.code === "PR_CONFLICT").length;
71
+ const builds = dev.filter((f) => f.code === "NOT_BUILT_PR").length;
72
+ const bits = [];
73
+ if (conflicts) bits.push(`${conflicts} conflict(s)${conflicts <= fixable ? " (1-click fix)" : ""}`);
74
+ if (builds) bits.push(`${builds} need a build`);
75
+ return `${dev.length} change(s) need your attention: ${bits.join(", ") || "see below"}.`;
76
+ }
77
+
78
+ /**
79
+ * The PRE-FILLED support escalation, generated ONLY when there are PLATFORM-owned
80
+ * findings (a retry didn't clear it) — so "I'm stuck" becomes a structured report.
81
+ * Returns null when nothing is platform-owned. Pure.
82
+ */
83
+ export function supportContext(result) {
84
+ const platform = result.findings.filter((f) => f.ownership === OWNERSHIP.platform);
85
+ if (!platform.length) return null;
86
+ const lines = [
87
+ `Tenant scope: ${result.scopeKnown ? "supplied" : "unknown"}`,
88
+ `Verdict: ${result.verdict}`,
89
+ "Platform-owned issues (a retry did not clear these — please investigate):",
90
+ ...platform.map((f) => ` • [${f.code}] ${f.subject} — ${f.detail}`),
91
+ "Next diagnostic hop: scripts/preview/pipeline-doctor.sh <tenant> --commit <headSha> (reconcile plane).",
92
+ ];
93
+ return lines.join("\n");
94
+ }
95
+
96
+ /** Render the analysis as the compact, ordered, agent-cheap report. Pure. */
97
+ export function formatReport(result) {
98
+ const lines = [];
99
+ lines.push("== gitea-merge-doctor ==");
100
+ lines.push(`VERDICT: ${result.verdict}`);
101
+ lines.push(attentionBanner(result));
102
+ lines.push("");
103
+ for (const f of result.findings) {
104
+ const act = f.action ? ` [action: ${f.action.label}]` : "";
105
+ lines.push(`${SEV_GLYPH[f.severity] ?? "?"} [${f.code}] (${OWN_TAG[f.ownership] ?? f.ownership}) ${f.subject}${act}`);
106
+ lines.push(` ${f.detail}`);
107
+ lines.push(` → ${f.remedy}`);
108
+ }
109
+ lines.push("");
110
+ lines.push(
111
+ `${result.counts.blocker} blocker(s), ${result.counts.warn} warning(s), ${result.counts.info} info. First ✗/⚠ above is the thing to fix.`,
112
+ );
113
+ const support = supportContext(result);
114
+ if (support) {
115
+ lines.push("");
116
+ lines.push("── if a platform issue persists after Retry, escalate with this (no free-text “stuck”): ──");
117
+ lines.push(support);
118
+ }
119
+ return lines.join("\n");
120
+ }
121
+
122
+ // ── C5: auto-surface on failure (the PUSH) ───────────────────────────────────────
123
+
124
+ /** How many findings the COMPACT summary lists before it defers the rest to the
125
+ * full `tot preview doctor` report — enough to name the thing to fix, not the whole
126
+ * multi-page report at a failure moment. */
127
+ export const COMPACT_FINDING_LIMIT = 3;
128
+
129
+ /**
130
+ * Render the COMPACT "here's what's blocking you" summary appended to a failed
131
+ * accept/integrate or a refused ship (C5). Unlike {@link formatReport} (the full
132
+ * on-demand report), this lists ONLY the actionable (blocker/warn) findings, capped
133
+ * at {@link COMPACT_FINDING_LIMIT}, and points at the full report for the rest. Returns
134
+ * an EMPTY array when there's nothing actionable to say (a clean/info-only verdict, or
135
+ * no analysis at all) — so a failure whose cause the doctor can't see gets NO noise
136
+ * appended. Pure — returns the lines to print, never prints itself.
137
+ * @param {ReturnType<typeof normalizeAnalysis>|null} result
138
+ * @param {{ limit?: number }} [opts]
139
+ * @returns {string[]}
140
+ */
141
+ export function formatCompactSummary(result, { limit = COMPACT_FINDING_LIMIT } = {}) {
142
+ if (!result || !Array.isArray(result.findings)) return [];
143
+ const actionable = result.findings.filter((f) => f?.severity === "blocker" || f?.severity === "warn");
144
+ if (!actionable.length) return [];
145
+ const lines = [`\n ── merge doctor — what's blocking your merges:`];
146
+ lines.push(` ${attentionBanner(result)}`);
147
+ for (const f of actionable.slice(0, limit)) {
148
+ const act = f.action?.label ? ` [${f.action.label}]` : "";
149
+ lines.push(` ${SEV_GLYPH[f.severity] ?? "?"} [${f.code}] (${OWN_TAG[f.ownership] ?? f.ownership}) ${f.subject}${act}`);
150
+ if (f.remedy) lines.push(` → ${f.remedy}`);
151
+ }
152
+ const more = actionable.length - limit;
153
+ lines.push(
154
+ more > 0
155
+ ? ` …and ${more} more — run \`tot preview doctor\` for the full report.`
156
+ : ` Run \`tot preview doctor\` for the full report.`,
157
+ );
158
+ return lines;
159
+ }
160
+
161
+ /**
162
+ * One GET to the hosted merge-doctor over an ALREADY-RESOLVED transport (the SAME
163
+ * `base` + `authHeaders` the calling verb used to reach `/api/changes` /
164
+ * `/api/changes/ship`), normalized to the analysis shape. Best-effort by contract:
165
+ * ANY failure (unreachable, non-2xx incl. the 401/403 an auth-refused caller would
166
+ * also hit, non-JSON body, a throw) resolves to `null` — the doctor is a diagnostic
167
+ * ADD-ON at a failure moment, so it must never itself become a second failure. Pure
168
+ * given the injected fetch.
169
+ * @param {{ base:string, authHeaders:Record<string,string> }} transport
170
+ * @param {typeof fetch} [fetchImpl]
171
+ * @returns {Promise<ReturnType<typeof normalizeAnalysis>|null>}
172
+ */
173
+ export async function fetchDoctorAnalysis({ base, authHeaders }, fetchImpl = globalThis.fetch) {
174
+ try {
175
+ const res = await fetchImpl(`${String(base).replace(/\/+$/, "")}${DOCTOR_PATH}`, {
176
+ method: "GET",
177
+ headers: authHeaders,
178
+ });
179
+ if (!res || !res.ok) return null;
180
+ let data = {};
181
+ try {
182
+ data = await res.json();
183
+ } catch {
184
+ return null;
185
+ }
186
+ return normalizeAnalysis(data);
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * C5 entry point: fetch the merge-doctor over the given transport and render the
194
+ * COMPACT failure summary. Returns the lines to print (or `[]` when there's nothing
195
+ * actionable / the doctor couldn't be reached). Never throws — a caller can
196
+ * `for (const l of await autoSurfaceDoctor(...)) console.error(l)` unconditionally at
197
+ * a failure return without touching the exit code.
198
+ * @param {{ base:string, authHeaders:Record<string,string>, fetchImpl?:typeof fetch, limit?:number }} opts
199
+ * @returns {Promise<string[]>}
200
+ */
201
+ export async function autoSurfaceDoctor({ base, authHeaders, fetchImpl = globalThis.fetch, limit }) {
202
+ try {
203
+ const analysis = await fetchDoctorAnalysis({ base, authHeaders }, fetchImpl);
204
+ return formatCompactSummary(analysis, { limit });
205
+ } catch {
206
+ return [];
207
+ }
208
+ }