@tokenoftrust/cli 1.4.0 → 1.5.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.
Files changed (54) hide show
  1. package/README.md +5 -0
  2. package/bin/tot.mjs +148 -57
  3. package/package.json +6 -1
  4. package/src/activity.mjs +379 -0
  5. package/src/app-scaffold.mjs +4 -4
  6. package/src/auth.mjs +13 -5
  7. package/src/candidate-state.mjs +3 -3
  8. package/src/commands/accept.mjs +498 -59
  9. package/src/commands/app/dev.mjs +8 -4
  10. package/src/commands/app/index.mjs +3 -3
  11. package/src/commands/app/scaffold.mjs +1 -1
  12. package/src/commands/branches.mjs +297 -0
  13. package/src/commands/cleanup.mjs +264 -0
  14. package/src/commands/clone.mjs +307 -25
  15. package/src/commands/dev.mjs +440 -156
  16. package/src/commands/doctor.mjs +4 -4
  17. package/src/commands/git-credential.mjs +180 -0
  18. package/src/commands/go-live.mjs +9 -5
  19. package/src/commands/grants.mjs +7 -5
  20. package/src/commands/hotfix.mjs +428 -0
  21. package/src/commands/ideas.mjs +2 -2
  22. package/src/commands/link.mjs +2 -2
  23. package/src/commands/login.mjs +5 -6
  24. package/src/commands/pr.mjs +62 -25
  25. package/src/commands/preview-build.mjs +6 -6
  26. package/src/commands/preview-doctor.mjs +225 -0
  27. package/src/commands/preview-retry-evidence.mjs +156 -0
  28. package/src/commands/preview.mjs +19 -3
  29. package/src/commands/revert.mjs +322 -0
  30. package/src/commands/rollback.mjs +18 -16
  31. package/src/commands/ship.mjs +51 -14
  32. package/src/commands/start.mjs +101 -59
  33. package/src/commands/submit.mjs +1183 -169
  34. package/src/commands/sync.mjs +203 -0
  35. package/src/commands/validate.mjs +10 -4
  36. package/src/commands/whoami.mjs +1 -1
  37. package/src/dev-heartbeat.mjs +3 -2
  38. package/src/dev-logs.mjs +2 -2
  39. package/src/errors.mjs +11 -4
  40. package/src/git-credential.mjs +257 -0
  41. package/src/last-tenant.mjs +1 -1
  42. package/src/mcp.mjs +6 -1
  43. package/src/merge-doctor-report.mjs +208 -0
  44. package/src/no-gitea-links.test.mjs +55 -0
  45. package/src/oauth.mjs +18 -14
  46. package/src/obstacle-beacon.cjs +2 -2
  47. package/src/obstacle.mjs +1 -1
  48. package/src/plan.mjs +83 -15
  49. package/src/sample.mjs +4 -4
  50. package/src/validate.mjs +187 -15
  51. package/src/vendor/private-apps-devkit.mjs +3 -3
  52. package/src/viewer-session.mjs +118 -0
  53. package/template/private-app/README.md +12 -6
  54. package/src/commands/retire.mjs +0 -203
@@ -14,9 +14,10 @@
14
14
  *
15
15
  * This is submit-for-PREVIEW, not ship-to-live (`change_accept`/`candidate_accept` /
16
16
  * a future `tot ship` is the separate ship gate — this command is submit-only, never
17
- * accept/reject). Step 2b is best-effort: `candidate_open` failing (older MCP,
18
- * version-control not configured, preview-access capability) is reported and
19
- * swallowed it never blocks the preview push that already landed. Step 3 calls
17
+ * accept/reject). Step 2b remains best-effort for compatibility failures (older MCP,
18
+ * version-control not configured, preview-access capability), but an attribution
19
+ * refusal is REQUIRED and exits non-zero with identity-recovery guidance. The preview
20
+ * push may already have landed, but no unaudited PR is created. Step 3 calls
20
21
  * the MCP `preview_status` read-back: given the commit just pushed it returns
21
22
  * { status, reconcile, compliance, previewUrl } and we poll it while reconcile is
22
23
  * pending. If that tool isn't present (older MCP) the command still validates +
@@ -40,19 +41,27 @@
40
41
  * Dependency-free (global fetch + `git`).
41
42
  */
42
43
  import { execFileSync } from "node:child_process";
44
+ import { readFileSync, existsSync } from "node:fs";
45
+ import { resolve as resolvePath } from "node:path";
43
46
  import { createHash } from "node:crypto";
44
47
  import { setTimeout as delay } from "node:timers/promises";
45
48
  import { createMcpClient } from "../mcp.mjs";
46
49
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
47
50
  import { checkoutTenant } from "./clone.mjs";
51
+ // Reuse the operator side's `candidate_refresh` result normaliser (unit c3 — the
52
+ // born-rebased submit rebuilds a candidate onto the current base with the SAME
53
+ // engine `tot accept --refresh` uses, so the two read its result identically).
54
+ import { normalizeRefreshResult } from "./accept.mjs";
48
55
  import { validateTenant, ERROR } from "../validate.mjs";
49
56
  import { openBrowser } from "../open.mjs";
50
57
  import { startProgress } from "../progress.mjs";
51
58
  import { fail } from "../errors.mjs";
59
+ import { emitActivity } from "../activity.mjs";
52
60
  import {
53
61
  defaultCandidateStatePath,
54
62
  readActiveChangeId,
55
63
  writeActiveChangeId,
64
+ clearActiveChangeId,
56
65
  mintFreshChangeId,
57
66
  isTerminalCandidateState,
58
67
  isDefaultBranch,
@@ -60,6 +69,13 @@ import {
60
69
 
61
70
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
62
71
 
72
+ // The public storefront origin a shareable preview PR URL is composed against
73
+ // (see shareablePrUrl, below) — the SAME default `tot ship` uses (ship.mjs's
74
+ // DEFAULT_STOREFRONT_URL), so a preview link and a ship link always agree on
75
+ // which storefront they point at even though submit.mjs and ship.mjs never
76
+ // import from each other.
77
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
78
+
63
79
  // The git ref prefix an isolated candidate push lands under (b03 — stop
64
80
  // force-pushing the SHARED `preview` ref). ONE constant so a rename is trivial —
65
81
  // provisionally coordinated with the MCP-side candidate_open resolution (b02/b04),
@@ -73,11 +89,31 @@ export function candidateRefFor(changeId) {
73
89
  return `${CANDIDATE_REF_PREFIX}${changeId}`;
74
90
  }
75
91
 
92
+ /**
93
+ * D3: emit a git-op lifecycle event for `tot submit`/`tot preview` — one per git
94
+ * operation (commit / push), carrying its own success/failure, so the timeline sees
95
+ * the individual git steps, not just the outer command's invoked/result pair. Uses
96
+ * the `cli.command.result` catalog key with a `git.<op>` subcommand (a fixed, safe
97
+ * value). Fire-and-forget best-effort: a silent no-op without a hosted-bridge
98
+ * credential, never awaited, never throws, never alters the command. `errorClass` is
99
+ * a low-cardinality class (never a raw git stderr, which can carry a token/path).
100
+ * @param {string} op @param {boolean} ok
101
+ * @param {{ command?: string, durationMs?: number, errorClass?: string }} [opts]
102
+ */
103
+ function emitGitOp(op, ok, { command = "submit", durationMs, errorClass } = {}) {
104
+ void emitActivity({
105
+ action: "cli.command.result",
106
+ outcome: { status: ok ? "succeeded" : "failed", ...(durationMs != null ? { durationMs } : {}), ...(errorClass ? { errorClass } : {}) },
107
+ payload: { args: { command, subcommand: `git.${op}`, ...(durationMs != null ? { durationMs } : {}) } },
108
+ });
109
+ }
110
+
76
111
  export function parseArgs(argv) {
77
112
  // `ref: null` — an explicit `--ref` always wins; otherwise the push target is
78
113
  // derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
79
114
  // never a fixed shared default.
80
- const a = { mcp: null, identity: null, ref: null, skipValidate: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, new: false, help: false };
115
+ /** @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 }} */
116
+ 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 };
81
117
  for (let i = 0; i < argv.length; i++) {
82
118
  const t = argv[i];
83
119
  if (t === "--mcp") a.mcp = argv[++i];
@@ -85,12 +121,16 @@ export function parseArgs(argv) {
85
121
  else if (t === "--ref") a.ref = argv[++i];
86
122
  else if (t === "-m" || t === "--message") a.message = argv[++i];
87
123
  else if (t === "--summary") a.summary = argv[++i];
124
+ else if (t === "--summary-file") a.summaryFile = argv[++i];
125
+ else if (t === "--strategy") a.strategy = argv[++i];
126
+ else if (t === "--json") a.json = true;
88
127
  else if (t === "--skip-validate") a.skipValidate = true;
128
+ else if (t === "--skip-freshness") a.skipFreshness = true;
89
129
  else if (t === "--no-commit") a.noCommit = true;
90
130
  else if (t === "--no-wait") a.noWait = true;
91
131
  else if (t === "--watch") a.watch = true;
92
132
  else if (t === "--no-open") a.noOpen = true;
93
- else if (t === "--new") a.new = true;
133
+ else if (t === "--fork-candidate") a.forkCandidate = true;
94
134
  else if (t === "--help" || t === "-h") a.help = true;
95
135
  }
96
136
  return a;
@@ -106,23 +146,46 @@ export function renderUsage(verb = "preview") {
106
146
  return `tot ${verb} — submit your store for preview
107
147
 
108
148
  tot ${verb} validate → push the preview ref → stream the result
109
- tot ${verb} --new open a NEW candidate PR instead of updating your open one
149
+ tot ${verb} --fork-candidate open a SECOND, independently-tracked candidate (a
150
+ parallel dev path). Rarely needed — a fresh git
151
+ branch already gets its own candidate automatically
152
+ (git checkout is the PR switcher); use this only to
153
+ run two candidates from ONE branch.
110
154
  tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
111
155
  tot ${verb} --skip-validate push without the local lint (not recommended)
156
+ tot ${verb} --skip-freshness skip the stale-base check AND the born-rebased rebuild
157
+ (not recommended — may build a candidate rooted in an
158
+ already-superseded base)
159
+ tot ${verb} --strategy <s> how the born-rebased rebuild resolves a file changed on
160
+ BOTH sides when the base has moved: "merge" (real 3-way
161
+ merge, surfaces a resolve card on a genuine overlap — the
162
+ default), "ours" (keep yours), "theirs" (keep the store's)
112
163
  tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
113
164
  tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
114
165
  tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
115
166
  tot ${verb} --summary "<text>" longer description to accompany the title
167
+ tot ${verb} --summary-file <path> structured summary from a file — JSON
168
+ {intent,effect,verification,risk} or the labeled
169
+ Intent / User-visible effect / Verification /
170
+ Risk-rollback text block; pass "-" to read stdin
171
+ (mutually exclusive with --summary)
116
172
  tot ${verb} --no-wait push and exit without polling for the reconcile result
117
173
  tot ${verb} --no-open don't open the preview URL in the browser on success
174
+ tot ${verb} --json machine-readable result on stdout (candidate id, PR,
175
+ head SHA, preview URL, reconcile/compliance evidence)
176
+ — implies --no-open, no spinner
118
177
  tot ${verb} --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
119
178
 
120
179
  By default a re-run UPDATES your open candidate PR (like pushing more commits
121
- to a GitHub PR), rather than opening a new one each time. Use --new to fork a
122
- fresh candidate PR; the next plain \`tot ${verb}\` then updates THAT one. Manage your
123
- open candidates with \`tot pr\` (list / view / close). If your candidate was already
180
+ to a GitHub PR), rather than opening a new one each time. Manage your open
181
+ candidates with \`tot pr\` (list / view / close). If your candidate was already
124
182
  merged or closed, a re-run automatically opens a fresh one.
125
183
 
184
+ Starting a separate change? \`git checkout -b <branch>\` — a fresh branch gets its
185
+ own candidate automatically, and \`git checkout\` back and forth is how you switch
186
+ between them. --fork-candidate is an escape hatch for the rarer case of wanting a
187
+ SECOND candidate off the SAME branch; reach for a branch first.
188
+
126
189
  Once a preview reconciles cleanly, \`tot ship\` promotes it live.
127
190
 
128
191
  If you omit -m, a summary is generated from git (commit subject + the diff vs
@@ -161,14 +224,344 @@ export function buildChangeSummary({ message, summary, headSubject = "", statLin
161
224
 
162
225
  /** Print the change summary block — the SAME title/body carried into `candidate_open`
163
226
  * (below) as the PR title/description, so what the approver reads in the PR matches
164
- * what's printed here. */
165
- function printChangeSummary({ title, body, autoTitle }) {
227
+ * what's printed here. `quiet` (--json) suppresses the human-readable print — the
228
+ * same data reaches the caller via the JSON result instead (see buildJsonResult). */
229
+ function printChangeSummary({ title, body, autoTitle }, { quiet = false } = {}) {
230
+ if (quiet) return;
166
231
  console.log(`\n Change summary (for the approver / the change record):`);
167
232
  console.log(` ${title}`);
168
233
  for (const l of body) console.log(` ${l}`);
169
234
  if (autoTitle) console.log(` (auto-generated from git — pass -m "…" / --summary "…" to refine)`);
170
235
  }
171
236
 
237
+ // ─── structured candidate summary (--summary-file / stdin, P2 item 14) ──────────
238
+
239
+ /**
240
+ * The four fields the candidate body prefers (see
241
+ * docs/architecture/branch-lifecycle-and-integration-preview.md §"Change
242
+ * descriptions and AI assistance") — `key` is the JSON key, `label` the
243
+ * canonical text-block heading, `match` the label spellings
244
+ * parseLabeledSummary recognizes for that field (case-insensitive).
245
+ */
246
+ const STRUCTURED_FIELDS = [
247
+ { key: "intent", label: "Intent", match: /^intent$/i },
248
+ { key: "effect", label: "User-visible effect", match: /^(user-visible effect|effect)$/i },
249
+ { key: "verification", label: "Verification", match: /^verification$/i },
250
+ { key: "risk", label: "Risk / rollback", match: /^(risk\s*\/?\s*rollback|risk-rollback|risk)$/i },
251
+ ];
252
+
253
+ /**
254
+ * Parse the labeled four-field text block (`Intent: …` / `User-visible effect: …`
255
+ * / `Verification: …` / `Risk / rollback: …`) into `{intent, effect, verification,
256
+ * risk}` — a field's value continues across following lines until the next
257
+ * recognized label, so multi-line prose under one label is preserved. Unlabeled
258
+ * leading text (and anything before the first recognized label) is dropped —
259
+ * callers fall back to treating the whole file as freeform body when nothing
260
+ * matches at all. Pure — unit-tested.
261
+ * @param {string} text
262
+ * @returns {{intent?: string, effect?: string, verification?: string, risk?: string}}
263
+ */
264
+ export function parseLabeledSummary(text) {
265
+ const fields = {};
266
+ let current = null;
267
+ let buf = [];
268
+ const flush = () => {
269
+ if (current) fields[current] = buf.join("\n").trim();
270
+ buf = [];
271
+ };
272
+ for (const line of String(text).split(/\r?\n/)) {
273
+ const m = /^([A-Za-z][A-Za-z /-]*?)\s*:\s*(.*)$/.exec(line);
274
+ const field = m && STRUCTURED_FIELDS.find((f) => f.match.test(m[1].trim()));
275
+ if (field) {
276
+ flush();
277
+ current = field.key;
278
+ buf = m[2] ? [m[2]] : [];
279
+ } else if (current) {
280
+ buf.push(line);
281
+ }
282
+ }
283
+ flush();
284
+ return fields;
285
+ }
286
+
287
+ /**
288
+ * Parse `--summary-file` content as JSON — `{intent, effect, verification, risk}`
289
+ * (extra keys ignored, each value trimmed, blank/non-string values dropped).
290
+ * Returns null when the text isn't a JSON object at all, so the caller falls
291
+ * back to the labeled-text parser rather than treating a JSON parse error as
292
+ * "no fields". Pure — unit-tested.
293
+ * @param {string} text
294
+ * @returns {{intent?: string, effect?: string, verification?: string, risk?: string}|null}
295
+ */
296
+ export function parseJsonSummary(text) {
297
+ let obj;
298
+ try {
299
+ obj = JSON.parse(text);
300
+ } catch {
301
+ return null;
302
+ }
303
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
304
+ const fields = {};
305
+ for (const { key } of STRUCTURED_FIELDS) {
306
+ if (typeof obj[key] === "string" && obj[key].trim()) fields[key] = obj[key].trim();
307
+ }
308
+ return fields;
309
+ }
310
+
311
+ /**
312
+ * Render recognized structured fields back to the canonical labeled block — the
313
+ * SAME shape the contract prescribes — so a JSON or labeled-text `--summary-file`
314
+ * produces an identical PR/candidate body to a human writing
315
+ * `--summary "Intent: …"` by hand. Only fields actually present are emitted.
316
+ * Pure — unit-tested.
317
+ * @param {{intent?: string, effect?: string, verification?: string, risk?: string}} fields
318
+ * @returns {string[]}
319
+ */
320
+ export function formatStructuredSummary(fields) {
321
+ return STRUCTURED_FIELDS.filter(({ key }) => fields[key]).map(({ key, label }) => `${label}: ${fields[key]}`);
322
+ }
323
+
324
+ /**
325
+ * Resolve `--summary-file`/stdin content to `--summary`'s body text (so it
326
+ * flows through buildChangeSummary/candidate_open exactly like an inline
327
+ * `--summary`, per the "preserve -m/--summary unchanged" requirement — this is
328
+ * purely an alternate SOURCE for the same body string). JSON wins if the
329
+ * content parses as an object with at least one recognized field; else the
330
+ * labeled text block; else — no recognized structure at all — the raw content
331
+ * is used verbatim as freeform summary text, so automation isn't forced into
332
+ * the four-field shape. Pure — unit-tested.
333
+ * @param {string} text
334
+ * @returns {string}
335
+ */
336
+ export function summaryFromStructuredText(text) {
337
+ const trimmed = String(text ?? "").trim();
338
+ if (!trimmed) return "";
339
+ const json = parseJsonSummary(trimmed);
340
+ if (json && Object.keys(json).length) return formatStructuredSummary(json).join("\n");
341
+ const labeled = parseLabeledSummary(trimmed);
342
+ if (Object.keys(labeled).length) return formatStructuredSummary(labeled).join("\n");
343
+ return trimmed;
344
+ }
345
+
346
+ /** Read `--summary-file <path>` content — `"-"` reads stdin (fd 0), the same
347
+ * dash-means-stdin convention `tot app` uses (see app/dev.mjs readInput), so
348
+ * an LLM/automation caller can pipe the structured summary in without a temp
349
+ * file. Throws on a real read failure (missing file, permissions) — the
350
+ * caller reports it. */
351
+ export function readSummaryFileContent(pathOrDash) {
352
+ if (pathOrDash === "-") return readFileSync(0, "utf8");
353
+ return readFileSync(pathOrDash, "utf8");
354
+ }
355
+
356
+ // ─── stale-base freshness preflight (unit u16) ──────────────────────────────────
357
+
358
+ /** The candidate base branch every submit targets (mirrors tot-mcp's own
359
+ * `DEFAULT_CANDIDATE_BASE` and sync.mjs's `DEFAULT_SYNC_BRANCH` — the SAME
360
+ * protected branch by three different names in three different modules; kept
361
+ * a local constant here, not imported, since this file already resolves its
362
+ * own defaults independently of sync.mjs and candidate_open's server default). */
363
+ export const FRESHNESS_BASE_BRANCH = "preview";
364
+
365
+ /**
366
+ * Detect a STALE local view of the base branch before minting a candidate —
367
+ * the live-repeat incident this guards against: the checkout's `origin/preview`
368
+ * tracking ref was stale (recorded before a just-merged PR moved it), so a
369
+ * fresh `tot preview --fork-candidate` built a candidate rooted in the OLD tip and got an
370
+ * instant, entirely avoidable "not mergeable" the moment it was compared
371
+ * against the real, already-advanced `preview`.
372
+ *
373
+ * Compares what the LOCAL checkout believes the base's tip is
374
+ * (`refs/remotes/origin/<branch>`, only as fresh as the last explicit fetch)
375
+ * against its ACTUAL current tip on the forge (`git ls-remote`, a lightweight
376
+ * single-ref read — no full fetch, no local ref mutated). Returns the live
377
+ * remote sha when local's cached view is behind it, or `null` when: the two
378
+ * already agree, there is no local tracking ref to compare against yet (a
379
+ * checkout that has simply never fetched this branch — never a false block on
380
+ * that), or the remote can't be reached right now (a network hiccup must
381
+ * never block a submit that would otherwise succeed; the push itself is the
382
+ * real connectivity test). Pure git I/O via the injected runner — unit-tested.
383
+ * @param {(cargs:string[])=>string} git
384
+ * @param {string} branch
385
+ * @returns {string|null}
386
+ */
387
+ export function detectStaleBase(git, branch) {
388
+ let localSha = "";
389
+ try {
390
+ localSha = git(["rev-parse", "-q", "--verify", `refs/remotes/origin/${branch}`]).trim();
391
+ } catch {
392
+ return null; // never fetched this branch locally — nothing cached to be stale
393
+ }
394
+ if (!localSha) return null;
395
+ let remoteSha = "";
396
+ try {
397
+ remoteSha = (git(["ls-remote", "origin", branch]).split(/\s+/)[0] || "").trim();
398
+ } catch {
399
+ return null; // can't reach the remote right now — don't block on a network hiccup
400
+ }
401
+ if (!remoteSha || remoteSha === localSha) return null;
402
+ return remoteSha;
403
+ }
404
+
405
+ /**
406
+ * How many commits the base branch has advanced since this candidate forked off it
407
+ * (unit c2 — the shift-left base-drift warning). Conflicts in this loop are BASE
408
+ * DRIFT: a candidate branches off `<base>`, sits while OTHER candidates advance
409
+ * `<base>`, then settles mergeable=false at Accept time. This measures that drift
410
+ * cheaply and LOCALLY — the merge-base (fork point) of HEAD and the base's
411
+ * remote-tracking tip, then how many commits separate that fork point from the tip.
412
+ *
413
+ * PURE LOCAL git — reads `refs/remotes/origin/<branch>` (the last-fetched tip),
414
+ * never the network; it complements detectStaleBase (which confirms that tracking
415
+ * ref is itself current). Returns 0 — never a false warning — when there's nothing
416
+ * to compare against or the drift can't be positively determined: no base tracking
417
+ * ref yet, unrelated histories / no merge-base, HEAD already contains the tip, or
418
+ * any git failure. Non-blocking by contract: the caller warns on a positive count
419
+ * but always proceeds. Pure git I/O via the injected runner — unit-tested.
420
+ * @param {(cargs:string[])=>string} git
421
+ * @param {string} branch
422
+ * @returns {number}
423
+ */
424
+ export function baseCommitsBehind(git, branch) {
425
+ const baseTip = `refs/remotes/origin/${branch}`;
426
+ let tip = "";
427
+ try {
428
+ tip = git(["rev-parse", "-q", "--verify", baseTip]).trim();
429
+ } catch {
430
+ return 0; // no local tracking ref for the base — nothing to compare against
431
+ }
432
+ if (!tip) return 0;
433
+ let mergeBase = "";
434
+ try {
435
+ mergeBase = git(["merge-base", "HEAD", baseTip]).trim();
436
+ } catch {
437
+ return 0; // unrelated histories / no HEAD — nothing meaningful to count
438
+ }
439
+ if (!mergeBase || mergeBase === tip) return 0; // HEAD already contains the base tip
440
+ try {
441
+ const n = parseInt(git(["rev-list", "--count", `${mergeBase}..${baseTip}`]).trim(), 10);
442
+ return Number.isFinite(n) && n > 0 ? n : 0;
443
+ } catch {
444
+ return 0;
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Resolve the git ref to diff HEAD against for the candidate's file patch (and its
450
+ * summary). The candidate PR is `preview + your file changes`, and `candidate_open`
451
+ * SERVER-CUTS the candidate branch back to the current preview tip and re-applies the
452
+ * patch — so the patch MUST be the FULL delta of your branch vs its fork point off
453
+ * `preview`, never "what changed since my last candidate push". Diffing against your
454
+ * own candidate tracking ref is what silently produced an empty patch (and the wrong
455
+ * "no file changes" skip, so no PR opened) after a push that landed but failed to open
456
+ * its PR: that ref already equals HEAD, so the since-last-push delta is empty even
457
+ * though the change vs preview is 19 files. Prefer the merge-base (fork point) with
458
+ * `origin/<baseBranch>`; fall back to the candidate tracking ref, then HEAD~1, then ""
459
+ * (single-commit `git show`) when no base ref resolves. Pure git I/O via the injected
460
+ * runner — unit-tested.
461
+ * @param {(cargs:string[])=>string} git
462
+ * @param {string} ref the candidate ref (e.g. "candidate/local-abc")
463
+ * @param {string} [baseBranch] the preview base branch (default FRESHNESS_BASE_BRANCH)
464
+ * @returns {string}
465
+ */
466
+ export function resolvePatchBase(git, ref, baseBranch = FRESHNESS_BASE_BRANCH) {
467
+ const verify = (r) => {
468
+ try { return git(["rev-parse", "--verify", "--quiet", r]).trim(); } catch { return ""; }
469
+ };
470
+ const previewRef = `refs/remotes/origin/${baseBranch}`;
471
+ if (verify(previewRef)) {
472
+ try {
473
+ const forkPoint = git(["merge-base", "HEAD", previewRef]).trim();
474
+ if (forkPoint) return forkPoint;
475
+ } catch { /* unrelated histories — fall through */ }
476
+ }
477
+ const trackingRef = `refs/remotes/origin/${ref}`;
478
+ if (verify(trackingRef)) return trackingRef;
479
+ if (verify("HEAD~1")) return "HEAD~1";
480
+ return "";
481
+ }
482
+
483
+ // ─── born-rebased at submit (unit c3 — shift-left prevention #2) ─────────────────
484
+
485
+ /** The three strategies the born-rebased rebuild accepts, mirroring `tot accept
486
+ * --refresh` (accept.mjs) and the admin one-click resolver: `merge` (real 3-way,
487
+ * refuses on a genuine overlap), `ours` (keep yours), `theirs` (keep the store's).
488
+ * `merge` is the DEFAULT — auto-rebuild on pure drift, name the conflict on a real
489
+ * same-line overlap, never silently clobber a side. */
490
+ export const BORN_REBASED_STRATEGIES = ["ours", "theirs", "merge"];
491
+ export const DEFAULT_BORN_REBASED_STRATEGY = "merge";
492
+
493
+ /**
494
+ * Run `candidate_refresh` (the born-rebased rebuild, unit c3) over the ALREADY
495
+ * established MCP client — the candidate was just opened by candidate_open (step 2b)
496
+ * and the tenant scope is already bound, so this reuses that session rather than
497
+ * establishing its own (accept.mjs's runRefresh is the operator entry that does the
498
+ * sign-in; here the submit flow already holds the session). Rebuilds the candidate
499
+ * from the CURRENT base tip and re-applies its file changes under `strategy`. Returns
500
+ * the normalized result (status:"committed" is the only success). Best-effort: any
501
+ * throw — an owner-gated denial (candidate_refresh is app-owner gated), an older MCP
502
+ * without the tool, a transient failure — normalizes to a non-ok error result the
503
+ * caller submits-as-is on, NEVER blocking the push that already landed.
504
+ * @param {{callTool:Function}} client
505
+ * @param {{ repo: string, changeId: string, strategy: string }} opts
506
+ * @returns {Promise<ReturnType<typeof normalizeRefreshResult>>}
507
+ */
508
+ export async function runBornRebased(client, { repo, changeId, strategy }) {
509
+ try {
510
+ const raw = await client.callTool("candidate_refresh", { repo, changeId, strategy });
511
+ return normalizeRefreshResult(raw);
512
+ } catch (e) {
513
+ return { ...normalizeRefreshResult(null), status: "error", message: String(e?.message || e) };
514
+ }
515
+ }
516
+
517
+ /**
518
+ * The born-rebased SUCCESS lines (unit c3) — printed when the candidate rebuilt
519
+ * cleanly onto the current base tip, so it enters the queue already mergeable rather
520
+ * than settling not-mergeable at Accept time. `behind` is the base-drift count that
521
+ * triggered the rebuild. Pure — unit-tested.
522
+ * @param {{ behind?: number, strategy?: string, refreshedFiles?: string[] }} input
523
+ * @returns {string[]}
524
+ */
525
+ export function formatBornRebasedSuccess({ behind = 0, strategy = DEFAULT_BORN_REBASED_STRATEGY, refreshedFiles = [] } = {}) {
526
+ const drift = behind > 0 ? `${behind} commit${behind === 1 ? "" : "s"}` : "since you forked";
527
+ const lines = [
528
+ `\n ✓ rebuilt fresh on the current store (base had advanced ${drift}, strategy ${strategy}) — your candidate enters the queue already mergeable.`,
529
+ ];
530
+ if (refreshedFiles.length) lines.push(` reapplied: ${refreshedFiles.join(", ")}`);
531
+ return lines;
532
+ }
533
+
534
+ /**
535
+ * The born-rebased RESOLVE CARD (unit c3) — printed when the default `merge` rebuild
536
+ * hits a GENUINE same-line overlap with the base (not mere drift): the biggest single
537
+ * shift-left win is surfacing that conflict HERE, at submit, instead of letting it
538
+ * land as a stuck queue row someone discovers at Accept time. Names the diverged files
539
+ * and offers the two one-command resolutions (keep-mine / keep-store's) — `merge` is
540
+ * the default that just failed, so it isn't re-offered. Matches how the rest of this
541
+ * file reports next-steps (a `✗` headline + concrete `tot <verb> …` commands), never a
542
+ * raw git rebase instruction. Pure — unit-tested. `verb` brands the copy with whatever
543
+ * the developer typed.
544
+ * @param {{ unresolved?: string[] }} rr
545
+ * @param {string} [verb]
546
+ * @returns {string[]}
547
+ */
548
+ export function formatBornRebasedConflict({ unresolved = [] } = {}, verb = "preview") {
549
+ const lines = [
550
+ `\n ✗ your change conflicts with the current store on the same lines — it can't be auto-rebased.`,
551
+ ];
552
+ if (unresolved.length) {
553
+ lines.push(` These files changed on both sides since you forked and need your call:`);
554
+ for (const f of unresolved) lines.push(` - ${f}`);
555
+ }
556
+ lines.push(
557
+ ` Resolve it in one command — choose which side wins on those files:`,
558
+ ` tot ${verb} --strategy=ours keep YOURS on any clash`,
559
+ ` tot ${verb} --strategy=theirs keep the STORE's on any clash`,
560
+ ` Your push is in; the candidate is submitted but stays not-mergeable until you resolve it.`,
561
+ );
562
+ return lines;
563
+ }
564
+
172
565
  // ─── auto-commit the known content trees (unit u2) ───────────────────────────────
173
566
 
174
567
  /**
@@ -253,6 +646,59 @@ export function buildAutoCommitMessage({ message, files = [], statLine = "" } =
253
646
  return body.length ? `${subject}\n\n${body.join("\n")}` : subject;
254
647
  }
255
648
 
649
+ /**
650
+ * Detect an in-progress rebase, merge, or cherry-pick in the workspace (unit u8) —
651
+ * `tot preview`/`tot submit` must NEVER auto-commit over one of these. A rebase that
652
+ * stopped at "Could not apply" (or a merge/cherry-pick left with real conflicts) IS
653
+ * a dirty tree from `git status`'s point of view, so `autoCommitKnownTrees` would
654
+ * otherwise stage + commit the half-resolved content straight into a plain "content
655
+ * update" commit — silently finishing the git operation WRONG and losing whatever
656
+ * edit was still sitting in conflict markers or unresolved hunks (the live incident
657
+ * this guards against: a stalled rebase was never continued, `tot preview` ran
658
+ * anyway, and the developer's own edit was gone). Detection is worktree-safe:
659
+ * MERGE_HEAD/CHERRY_PICK_HEAD via plumbing refs (no direct `.git` path assumption),
660
+ * and rebase-merge/rebase-apply via `--git-path` (a linked worktree's git-dir lives
661
+ * OUTSIDE `<workspace>/.git`, so a literal `.git/rebase-merge` check would miss it).
662
+ * Returns which operation is in progress, or null when the tree is clean of one.
663
+ * @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
664
+ * @param {string} workspace absolute path `git` runs `-C` against — `--git-path`'s
665
+ * output may be relative, and Node's `existsSync` resolves relative paths against
666
+ * the CLI process's own cwd, not the checkout, so this pins the resolution base.
667
+ * @returns {"rebase"|"merge"|"cherry-pick"|null}
668
+ */
669
+ export function detectInProgressGitOperation(git, workspace) {
670
+ const hasRef = (name) => {
671
+ try {
672
+ return git(["rev-parse", "-q", "--verify", name]).trim().length > 0;
673
+ } catch {
674
+ return false;
675
+ }
676
+ };
677
+ if (hasRef("MERGE_HEAD")) return "merge";
678
+ if (hasRef("CHERRY_PICK_HEAD")) return "cherry-pick";
679
+ const hasGitPath = (name) => {
680
+ try {
681
+ const p = git(["rev-parse", "--git-path", name]).trim();
682
+ // An empty result should never happen for a real `--git-path` (it always
683
+ // echoes SOME path, existing or not) — but treat it as "absent" rather than
684
+ // resolving it, since `resolvePath(workspace, "")` degrades to `workspace`
685
+ // itself, which trivially always exists (a false "rebase in progress" on
686
+ // every call, not just an occasional false negative).
687
+ return p.length > 0 && existsSync(resolvePath(workspace, p));
688
+ } catch {
689
+ return false;
690
+ }
691
+ };
692
+ if (hasGitPath("rebase-merge") || hasGitPath("rebase-apply")) return "rebase";
693
+ return null;
694
+ }
695
+
696
+ /** The abort command that recovers from each in-progress git operation, so the
697
+ * refusal below can tell a developer exactly what to run. Pure. */
698
+ export function abortCommandFor(op) {
699
+ return op === "merge" ? "git merge --abort" : op === "cherry-pick" ? "git cherry-pick --abort" : "git rebase --abort";
700
+ }
701
+
256
702
  /**
257
703
  * Auto-commit the known content trees before previewing (unit u2). On a DIRTY
258
704
  * tree `tot preview` commits your content edits for you, so a preview always
@@ -263,16 +709,21 @@ export function buildAutoCommitMessage({ message, files = [], statLine = "" } =
263
709
  * (preview whatever's already committed — u1's behavior).
264
710
  *
265
711
  * Returns exactly one of:
266
- * { skipped: true } — --no-commit.
267
- * { clean: true } nothing dirty; preview HEAD as-is.
268
- * { refused, unknown, known } — out-of-scope dirt; caller refuses + hints.
269
- * { committed: true, sha, files } — staged the known dirty paths and committed.
712
+ * { skipped: true } — --no-commit.
713
+ * { inProgress: "rebase"|… } a rebase/merge/cherry-pick is unresolved;
714
+ * caller refuses rather than auto-committing
715
+ * over the developer's own half-resolved tree.
716
+ * { clean: true } — nothing dirty; preview HEAD as-is.
717
+ * { refused, unknown, known } — out-of-scope dirt; caller refuses + hints.
718
+ * { committed: true, sha, files } — staged the known dirty paths and committed.
270
719
  *
271
720
  * @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
272
- * @param {{ message?: string|null, noCommit?: boolean }} [opts]
721
+ * @param {{ message?: string|null, noCommit?: boolean, workspace?: string }} [opts]
273
722
  */
274
- export function autoCommitKnownTrees(git, { message = null, noCommit = false } = {}) {
723
+ export function autoCommitKnownTrees(git, { message = null, noCommit = false, workspace = "." } = {}) {
275
724
  if (noCommit) return { skipped: true };
725
+ const inProgress = detectInProgressGitOperation(git, workspace);
726
+ if (inProgress) return { inProgress };
276
727
  const status = git(["-c", "core.quotePath=false", "status", "--porcelain", "--untracked-files=all"]);
277
728
  const dirty = parsePorcelainPaths(status);
278
729
  if (dirty.length === 0) return { clean: true };
@@ -327,16 +778,17 @@ export function parseNameStatus(text) {
327
778
  * @returns {Array<{path: string, content?: string, contentEncoding?: "base64", delete?: true}>}
328
779
  */
329
780
  export function buildFilePatch(entries, readBlob) {
781
+ /** @type {Array<{ path: string, content?: string, contentEncoding?: "base64", delete?: true }>} */
330
782
  const patch = [];
331
783
  for (const e of entries) {
332
- if (e.status === "R") patch.push({ path: e.from, delete: true });
784
+ if (e.status === "R") patch.push({ path: /** @type {string} */ (e.from), delete: true });
333
785
  if (e.status === "D") {
334
786
  patch.push({ path: e.path, delete: true });
335
787
  continue;
336
788
  }
337
789
  const buf = readBlob(e.path);
338
790
  const asUtf8 = buf.toString("utf8");
339
- const isCleanUtf8 = !asUtf8.includes("") && Buffer.from(asUtf8, "utf8").equals(buf);
791
+ const isCleanUtf8 = !asUtf8.includes("\x00") && Buffer.from(asUtf8, "utf8").equals(buf);
340
792
  patch.push(
341
793
  isCleanUtf8
342
794
  ? { path: e.path, content: asUtf8 }
@@ -366,41 +818,14 @@ export function repoNameFromRemote(remoteUrl) {
366
818
 
367
819
  // ─── fresh-forge-credential push (decision B — the invited-dev 401 dead-end) ─────
368
820
 
369
- /**
370
- * Split an authenticated forge remote URL (basic-auth `user:token@host`, as the
371
- * MCP mints it via `tenant_checkout`) into its tokenless public URL + the embedded
372
- * credential, so the token can be handed to git EPHEMERALLY for one push instead of
373
- * being persisted in `.git/config`. Returns null when the URL won't parse or carries
374
- * no token — the caller then falls back to the checkout's existing remote. Pure —
375
- * unit-tested.
376
- * @param {string} remoteUrl
377
- * @returns {{ publicUrl: string, username: string, token: string }|null}
378
- */
379
- export function splitAuthedRemote(remoteUrl) {
380
- try {
381
- const u = new URL(String(remoteUrl));
382
- const token = u.password ? decodeURIComponent(u.password) : "";
383
- if (!token) return null;
384
- const username = u.username ? decodeURIComponent(u.username) : "";
385
- return { publicUrl: `${u.protocol}//${u.host}${u.pathname}`, username, token };
386
- } catch {
387
- return null;
388
- }
389
- }
390
-
391
- /**
392
- * The `http.extraheader` value that hands a basic-auth credential to a SINGLE git
393
- * invocation (base64 of `user:token`) — so a freshly-minted forge token
394
- * authenticates one push without ever being written to `.git/config`. Pure —
395
- * unit-tested.
396
- * @param {string} username
397
- * @param {string} token
398
- * @returns {string}
399
- */
400
- export function basicAuthExtraHeader(username, token) {
401
- const b64 = Buffer.from(`${username}:${token}`, "utf8").toString("base64");
402
- return `Authorization: Basic ${b64}`;
403
- }
821
+ // splitAuthedRemote / basicAuthExtraHeader now live in ../git-credential.mjs (unit
822
+ // u10) a dependency-free module BOTH this file and clone.mjs/commands/
823
+ // git-credential.mjs need, so they moved out of here to avoid a submit.mjs
824
+ // clone.mjs import cycle. Re-exported so every existing import of these two names
825
+ // FROM submit.mjs (this file's own callers below, plus tests) keeps working
826
+ // unchanged.
827
+ export { splitAuthedRemote, basicAuthExtraHeader } from "../git-credential.mjs";
828
+ import { splitAuthedRemote, basicAuthExtraHeader, ensureTokenlessRemote } from "../git-credential.mjs";
404
829
 
405
830
  /**
406
831
  * Recognise a forge auth failure (expired / invalid push token) in a failed git
@@ -448,7 +873,7 @@ export function tagFromRepoName(repoName, tenant) {
448
873
  *
449
874
  * @param {(cargs:string[])=>string} git throwing git runner (execFileSync-backed)
450
875
  * @param {() => Promise<string|null>} mintRemote mints a fresh authed gitRemote (null when unavailable)
451
- * @param {{ ref: string }} opts
876
+ * @param {{ ref?: string }} [opts]
452
877
  * @returns {Promise<{ out: string }>} resolves on a successful push; throws (git's error) otherwise
453
878
  */
454
879
  export async function pushPreviewRef(git, mintRemote, { ref } = {}) {
@@ -534,25 +959,79 @@ export function actorKeyFor(session) {
534
959
  * Which candidate this submit lands on (gh-pr-like) — decided UP FRONT, before any
535
960
  * network call, because it also determines the isolated git ref we push to
536
961
  * (resolvePushRef, below): a re-submit updates the SAME candidate/ref by default;
537
- * `--new` forks a fresh one.
538
- * --new → fork a FRESH candidate id;
539
- * otherwise → the remembered active candidate (from a prior --new / terminal
540
- * roll), else the STABLE per-dev-per-tenant(-per-branch) default.
962
+ * `--fork-candidate` forks a fresh one.
963
+ * forkCandidate → fork a FRESH candidate id;
964
+ * otherwise → the remembered active candidate (from a prior --fork-candidate /
965
+ * terminal roll), else the STABLE per-dev-per-tenant(-per-branch)
966
+ * default.
541
967
  * `persist` reports whether the choice diverges from the stable default, so the
542
968
  * caller knows whether to remember it as the new active pointer. `mint` is
543
969
  * injected (defaults to mintFreshChangeId) so this is pure/deterministic in tests.
544
970
  * Pure — unit-tested.
545
971
  * @param {{ tenant: string, actorKey: string, branch?: string|null, active?: string|null,
546
- * isNew?: boolean, mint?: (baseId: string) => string }} opts
972
+ * forkCandidate?: boolean, mint?: (baseId: string) => string }} opts
547
973
  * @returns {{ changeId: string, stableId: string, persist: boolean }}
548
974
  */
549
- export function chooseChangeId({ tenant, actorKey, branch = null, active = null, isNew = false, mint = mintFreshChangeId }) {
975
+ export function chooseChangeId({ tenant, actorKey, branch = null, active = null, forkCandidate = false, mint = mintFreshChangeId }) {
550
976
  const stableId = deriveChangeId(tenant, actorKey, branch);
551
- const changeId = isNew ? mint(stableId) : (active || stableId);
552
- const persist = isNew || (!!active && active !== stableId);
977
+ const changeId = forkCandidate ? mint(stableId) : (active || stableId);
978
+ const persist = forkCandidate || (!!active && active !== stableId);
553
979
  return { changeId, stableId, persist };
554
980
  }
555
981
 
982
+ /**
983
+ * The forge state of ONE candidate (`"open"`/`"merged"`/`"closed"`/…), read by
984
+ * changeId via `candidate_status`, or null when it can't be POSITIVELY determined —
985
+ * the candidate isn't found, carries no state, or the read throws. null ("couldn't
986
+ * tell") is the deliberately SAFE answer: the caller (resolveActivePointer) then
987
+ * behaves exactly as if the pointer were still live, so a purely-diagnostic check
988
+ * that can't run never blocks, changes, or crashes a submit (u17 acceptance #3).
989
+ * Tolerates the tool returning a bare candidate, a `{candidates:[…]}` list, or a
990
+ * plain array. Injectable client for tests.
991
+ * @param {{callTool:Function}} client
992
+ * @param {{ repo: string, changeId: string }} opts
993
+ * @returns {Promise<string|null>}
994
+ */
995
+ export async function candidateStateFor(client, { repo, changeId }) {
996
+ try {
997
+ const r = await client.callTool("candidate_status", { repo, changeId });
998
+ const c = Array.isArray(r)
999
+ ? r.find((x) => x?.changeId === changeId)
1000
+ : Array.isArray(r?.candidates)
1001
+ ? r.candidates.find((x) => x?.changeId === changeId)
1002
+ : r;
1003
+ return c && typeof c.state === "string" ? c.state : null;
1004
+ } catch {
1005
+ return null;
1006
+ }
1007
+ }
1008
+
1009
+ /**
1010
+ * u17 — before REUSING a remembered active-candidate pointer, confirm its PR is
1011
+ * still open. The live incident this guards against: after a candidate PR merged, a
1012
+ * plain `tot preview` reused the remembered pointer, force-pushed onto the now-DEAD
1013
+ * candidate branch (stale old-base history), and `candidate_open` opened a NEW PR
1014
+ * from it — inheriting a guaranteed conflict from the very first commit. When the
1015
+ * pointer's PR has gone terminal (merged/closed) we DROP it here, so `chooseChangeId`
1016
+ * falls back to the stable per-branch id exactly as if no pointer existed.
1017
+ *
1018
+ * PURELY DIAGNOSTIC — never blocks a submit over the check itself: no pointer, no
1019
+ * repo, or a check that errors / can't positively confirm terminal all resolve to
1020
+ * `{ active }` UNCHANGED (behave exactly as before). Only a POSITIVELY terminal
1021
+ * state drops the pointer. When it does, `dropped` carries the old changeId + the
1022
+ * terminal state so the caller can tell the operator and forget the on-disk pointer.
1023
+ * Injectable client for tests.
1024
+ * @param {{callTool:Function}} client
1025
+ * @param {{ repo: string|null, active: string|null }} opts
1026
+ * @returns {Promise<{ active: string|null, dropped?: { changeId: string, state: string } }>}
1027
+ */
1028
+ export async function resolveActivePointer(client, { repo, active }) {
1029
+ if (!active || !repo) return { active };
1030
+ const state = await candidateStateFor(client, { repo, changeId: active });
1031
+ if (isTerminalCandidateState(state)) return { active: null, dropped: { changeId: active, state: /** @type {string} */ (state) } };
1032
+ return { active };
1033
+ }
1034
+
556
1035
  /**
557
1036
  * The ref `tot preview`/`tot submit` pushes to (b03 — stop force-pushing the
558
1037
  * SHARED `preview` ref). An explicit `--ref` always wins — the escape hatch /
@@ -568,6 +1047,18 @@ export function resolvePushRef({ ref, changeId }) {
568
1047
  return ref || candidateRefFor(changeId);
569
1048
  }
570
1049
 
1050
+ const REQUIRED_ATTRIBUTION_REFUSALS = new Set([
1051
+ "audit_identity_unavailable",
1052
+ "audit_unavailable",
1053
+ ]);
1054
+
1055
+ class CandidateAttributionError extends Error {
1056
+ constructor(message) {
1057
+ super(message);
1058
+ this.name = "CandidateAttributionError";
1059
+ }
1060
+ }
1061
+
571
1062
  /**
572
1063
  * Open/update the PR-backed candidate for this submit (g1b `candidate_open`,
573
1064
  * unit c1 — the local-dev-loop half of the "PR-Backed Hosted Review Loop"
@@ -576,21 +1067,23 @@ export function resolvePushRef({ ref, changeId }) {
576
1067
  * title/body as the PR title/description, and prints the resulting
577
1068
  * changeId/PR number/URL. Best-effort: any failure (no repo could be derived,
578
1069
  * older MCP, version control not configured, preview-access capability, …) is
579
- * reported and swallowed — it never blocks the preview push that already
580
- * landed or the reconcile/compliance read-back that follows.
1070
+ * reported and swallowed — except an audit/identity refusal, which is required
1071
+ * and propagates so the command exits non-zero with the MCP's recovery guidance.
581
1072
  * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
582
1073
  * @param {{ repo: string|null, changeId: string, changeSummary: {title:string, body:string[]},
583
- * patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer }} opts
1074
+ * patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer,
1075
+ * quiet?: boolean }} opts `quiet` (--json) suppresses the human print; the same
1076
+ * result is still returned for the caller's JSON payload.
584
1077
  */
585
- export async function submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob }) {
1078
+ export async function submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet = false }) {
586
1079
  if (!repo) {
587
- console.log(` ~ couldn't derive the forge repo from the checkout's remote — skipping the PR-backed candidate.`);
1080
+ if (!quiet) console.log(` ~ couldn't derive the forge repo from the checkout's remote — skipping the PR-backed candidate.`);
588
1081
  return null;
589
1082
  }
590
1083
  try {
591
1084
  const patch = buildFilePatch(patchEntries, readBlob);
592
1085
  if (patch.length === 0) {
593
- console.log(` ~ no file changes to open a PR-backed candidate for.`);
1086
+ if (!quiet) console.log(` ~ no file changes to open a PR-backed candidate for.`);
594
1087
  return null;
595
1088
  }
596
1089
  const result = await client.callTool("candidate_open", {
@@ -603,33 +1096,109 @@ export async function submitCandidate(client, { repo, changeId, changeSummary, p
603
1096
  body: changeSummary.body.length ? changeSummary.body.join("\n").slice(0, 4000) : undefined,
604
1097
  patch,
605
1098
  });
606
- reportCandidate(result, changeId);
1099
+ if (REQUIRED_ATTRIBUTION_REFUSALS.has(result?.status)) {
1100
+ throw new CandidateAttributionError(
1101
+ result?.message || "Candidate not created: verified actor attribution is required.",
1102
+ );
1103
+ }
1104
+ reportCandidate(result, changeId, { quiet });
607
1105
  return result;
608
1106
  } catch (e) {
609
- console.log(` ~ couldn't open/update the PR-backed candidate: ${String(e?.message || e)}`);
610
- console.log(` (best-effort — your push is still in; this doesn't block reconcile.)`);
1107
+ if (e instanceof CandidateAttributionError) throw e;
1108
+ if (!quiet) {
1109
+ console.log(` ~ couldn't open/update the PR-backed candidate: ${String(e?.message || e)}`);
1110
+ console.log(` (best-effort — your push is still in; this doesn't block reconcile.)`);
1111
+ }
611
1112
  return null;
612
1113
  }
613
1114
  }
614
1115
 
615
1116
  /** Print the candidate_open result: the PR the approver reviews, or the MCP's own
616
- * refusal message when it couldn't open/update one. */
617
- function reportCandidate(result, changeId) {
1117
+ * refusal message when it couldn't open/update one. `quiet` (--json) suppresses it.
1118
+ * Deliberately does NOT print `result.url` — that's the INTERNAL forge (Gitea) PR
1119
+ * link, plumbing a developer never needs to see (DZ, 2026-08-15); the product
1120
+ * surface is the shareable /preview/<tenant>/pr/<N> URL printed right after
1121
+ * (shareablePrUrl). The forge URL still rides the --json payload for tooling. */
1122
+ function reportCandidate(result, changeId, { quiet = false } = {}) {
1123
+ if (quiet) return;
618
1124
  if (result && typeof result.prNumber === "number") {
619
1125
  console.log(`\n ✓ candidate ${result.changeId || changeId} — PR #${result.prNumber} (${result.state || "open"})`);
620
- if (result.url) console.log(` ${result.url}`);
621
1126
  return;
622
1127
  }
623
1128
  const msg = result?.message || (result?.raw && String(result.raw)) || JSON.stringify(result ?? null);
624
1129
  console.log(` ~ PR-backed candidate not opened: ${msg}`);
625
1130
  }
626
1131
 
1132
+ /**
1133
+ * Build the `--json` result object (P2 item 14): candidate id, PR, head SHA,
1134
+ * shareable preview URL, and reconcile/compliance evidence — so an
1135
+ * LLM/automation caller can consume structured data instead of scraping
1136
+ * human-readable stdout. `ok` mirrors the process exit code (0 ⇒ true) so a
1137
+ * caller can branch on one field.
1138
+ *
1139
+ * Also carries the honest-dispatch triad (the "never dispatched" fix) so
1140
+ * automation gets the SAME truth the human-readable path does, never a
1141
+ * prettier lie: `dispatched` (was a webhook delivery ever observed for this
1142
+ * commit?), `notDispatched` (the permanent-dead-end tag from
1143
+ * pollPreviewStatus — re-running will not help), and `delivery` (the raw
1144
+ * observability triad, or null when nothing was ever seen). All three
1145
+ * default to their "nothing known yet" value when `status` is absent/older,
1146
+ * so a caller can branch on `notDispatched` unconditionally without a
1147
+ * presence check.
1148
+ *
1149
+ * `previewPrUrl` (P2 item — the immediate Vercel-style shareable link, see
1150
+ * shareablePrUrl) is threaded through separately from `previewUrl`: the
1151
+ * latter is the server-minted, reconcile-confirmed link (null until reconcile
1152
+ * actually lands); the former is composed client-side the instant the
1153
+ * candidate PR opens and may point at a preview that's still building.
1154
+ * Defaults to null when no numeric PR number was known at result-build time.
1155
+ * Pure — unit-tested.
1156
+ * @param {{ ok: boolean, ref?: string|null, commit?: string|null, changeId?: string|null,
1157
+ * candidate?: {prNumber?: number, number?: number, state?: string, url?: string}|null,
1158
+ * status?: {status?: string, reconcile?: object|null, compliance?: object|null,
1159
+ * previewUrl?: string|null, shipped?: object|null, dispatched?: boolean|null,
1160
+ * notDispatched?: boolean, delivery?: object|null}|null,
1161
+ * previewPrUrl?: string|null, error?: string|null, note?: string|null }} input
1162
+ */
1163
+ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null, candidate = null, status = null, previewPrUrl = null, error = null, note = null }) {
1164
+ return {
1165
+ ok,
1166
+ ref,
1167
+ commit,
1168
+ changeId,
1169
+ candidate: candidate
1170
+ ? { number: candidate.prNumber ?? candidate.number ?? null, state: candidate.state ?? null, url: candidate.url ?? null }
1171
+ : null,
1172
+ status: status?.status ?? null,
1173
+ reconcile: status?.reconcile ?? null,
1174
+ compliance: status?.compliance ?? null,
1175
+ previewUrl: status?.previewUrl ?? null,
1176
+ shipped: status?.shipped ?? null,
1177
+ dispatched: status?.dispatched ?? null,
1178
+ notDispatched: status?.notDispatched ?? false,
1179
+ forwardFailed: /** @type {any} */ (status)?.forwardFailed ?? false,
1180
+ delivery: status?.delivery ?? null,
1181
+ previewPrUrl,
1182
+ ...(error ? { error } : {}),
1183
+ ...(note ? { note } : {}),
1184
+ };
1185
+ }
1186
+
1187
+ /** Print the `--json` result as one pretty-printed object on stdout — a no-op
1188
+ * unless `args.json` was passed, so call sites can invoke it unconditionally. */
1189
+ function emitJson(args, payload) {
1190
+ if (args.json) console.log(JSON.stringify(payload, null, 2));
1191
+ }
1192
+
627
1193
  /**
628
1194
  * The preview flow — validate, push the preview ref, open/update the PR-backed
629
1195
  * candidate, and stream back the reconcile/compliance/preview result. Reached by
630
1196
  * `tot preview` and, as teaching aliases, `tot submit` / `tot deploy` (preview.mjs
631
1197
  * wraps this and adds the verb-teaching hints). `verb` only brands the user-facing
632
1198
  * copy (usage + the not-in-checkout error) with whatever the developer typed.
1199
+ * `--json` (args.json) suppresses the human-readable stdout narration in favor of
1200
+ * one structured result object at the end (see buildJsonResult) — stderr
1201
+ * diagnostics (fail(), `~ …` progress lines) still print either way.
633
1202
  * @param {string[]} argv @param {any} ctx @param {{ verb?: string }} [opts]
634
1203
  */
635
1204
  export async function run(argv, ctx, { verb = "preview" } = {}) {
@@ -639,19 +1208,103 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
639
1208
  console.log(renderUsage(verb));
640
1209
  return 0;
641
1210
  }
1211
+ if (args.summary && args.summaryFile) {
1212
+ const msg = "--summary and --summary-file are mutually exclusive";
1213
+ console.error(fail(msg, "pass one or the other"));
1214
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1215
+ return 2;
1216
+ }
1217
+ // --strategy (c3 born-rebased rebuild) — validate whenever given; defaults to
1218
+ // "merge" (auto-rebuild on drift, surface a resolve card on a genuine overlap).
1219
+ if (args.strategy != null && !BORN_REBASED_STRATEGIES.includes(args.strategy)) {
1220
+ const msg = `unknown --strategy "${args.strategy}"`;
1221
+ console.error(fail(msg, `use one of: ${BORN_REBASED_STRATEGIES.join(", ")} (default "${DEFAULT_BORN_REBASED_STRATEGY}")`));
1222
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1223
+ return 2;
1224
+ }
1225
+ const bornRebasedStrategy = args.strategy || DEFAULT_BORN_REBASED_STRATEGY;
1226
+ if (args.summaryFile) {
1227
+ let raw;
1228
+ try {
1229
+ raw = readSummaryFileContent(args.summaryFile);
1230
+ } catch (e) {
1231
+ const msg = `couldn't read --summary-file ${args.summaryFile}: ${String(e?.message || e)}`;
1232
+ console.error(fail(msg, `check the path (or pass "-" to read stdin)`));
1233
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1234
+ return 2;
1235
+ }
1236
+ // Feeds --summary's body unchanged from here on (buildChangeSummary etc.) —
1237
+ // --summary-file is purely an alternate SOURCE for the same string, per the
1238
+ // "preserve -m/--summary unchanged" requirement.
1239
+ args.summary = summaryFromStructuredText(raw);
1240
+ }
642
1241
  if (ctx.mode !== "checkout") {
643
- console.error(
644
- fail(
645
- `\`tot ${verb}\` runs from inside a tenant checkout`,
646
- "tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)",
647
- ),
648
- );
1242
+ const msg = `\`tot ${verb}\` runs from inside a tenant checkout`;
1243
+ console.error(fail(msg, "tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)"));
1244
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
649
1245
  return 2;
650
1246
  }
651
1247
  const workspace = ctx.workspacePath;
652
1248
  const tenant = ctx.tenant;
653
1249
  const git = (cargs) => execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
654
1250
 
1251
+ // Self-heal a LEGACY checkout (unit u10): strip any token still embedded in
1252
+ // `origin`'s URL and install the credential helper, so this run (and every one
1253
+ // after) mints fresh creds through `tot` instead of relying on one that quietly
1254
+ // expired. Best-effort — never blocks the actual preview on a migration hiccup.
1255
+ try {
1256
+ ensureTokenlessRemote(git);
1257
+ } catch {
1258
+ /* best-effort — see above */
1259
+ }
1260
+
1261
+ // How far the base has drifted since this candidate forked (unit c2), hoisted to the
1262
+ // whole flow: it drives BOTH the c2 pre-push warning (below) AND the c3 born-rebased
1263
+ // rebuild (after candidate_open). Stays 0 under --skip-freshness, so that flag opts
1264
+ // out of the rebuild too — consistent with opting out of the warning.
1265
+ let baseDrift = 0;
1266
+
1267
+ // Freshness preflight (unit u16) — BEFORE minting anything: is the checkout's
1268
+ // cached view of the base branch already behind the store? A candidate built
1269
+ // on a stale base is an instant, avoidable "not mergeable" the moment the
1270
+ // forge compares it against the real (already-advanced) base. --skip-freshness
1271
+ // opts out (e.g. offline/CI, or a deliberate re-run against a known-good tip).
1272
+ if (!args.skipFreshness) {
1273
+ let staleTip = null;
1274
+ try {
1275
+ staleTip = detectStaleBase(git, FRESHNESS_BASE_BRANCH);
1276
+ } catch {
1277
+ staleTip = null; // never block a submit on the preflight's OWN failure
1278
+ }
1279
+ if (staleTip) {
1280
+ const msg = `your checkout is behind the store — "${FRESHNESS_BASE_BRANCH}" has moved since your last sync`;
1281
+ console.error(
1282
+ fail(msg, `tot sync (fetches the latest ${FRESHNESS_BASE_BRANCH} + merges it into your branch, then re-run \`tot ${verb}\`) — or re-run with --skip-freshness to build anyway`),
1283
+ );
1284
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1285
+ return 1;
1286
+ }
1287
+
1288
+ // Base-drift warning (unit c2) — a candidate rooted on a fork point the base has
1289
+ // since moved past is what settles mergeable=false at Accept time; catch it early,
1290
+ // right here, with a pure-LOCAL git read (no network — uses the base's
1291
+ // remote-tracking tip, which the stale-base preflight just above confirmed is
1292
+ // current). NON-BLOCKING: warn and submit anyway, so a work-in-progress preview
1293
+ // is never refused over base drift. Shares --skip-freshness with the preflight.
1294
+ try {
1295
+ baseDrift = baseCommitsBehind(git, FRESHNESS_BASE_BRANCH);
1296
+ } catch {
1297
+ baseDrift = 0; // never let the warning's OWN failure disturb the submit
1298
+ }
1299
+ if (baseDrift > 0) {
1300
+ console.error(`\n⚠ base is ${baseDrift} commit${baseDrift === 1 ? "" : "s"} behind — rebase before submit`);
1301
+ console.error(
1302
+ ` (\`${FRESHNESS_BASE_BRANCH}\` has advanced since your branch forked off it — \`tot sync\` to rebase, or --skip-freshness to silence)`,
1303
+ );
1304
+ }
1305
+ }
1306
+
1307
+
655
1308
  // 0. auto-commit the known content trees (unit u2) — on a dirty tree, commit your
656
1309
  // content edits BEFORE previewing so the preview reflects your working changes.
657
1310
  // Only content/, public/, theme.json, .tot/ (staged by explicit path, never
@@ -659,14 +1312,27 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
659
1312
  // --no-commit opts out (preview whatever's already committed).
660
1313
  let auto;
661
1314
  try {
662
- auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit });
1315
+ auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit, workspace });
663
1316
  } catch (e) {
1317
+ emitGitOp("commit", false, { command: verb, errorClass: "git_commit_failed" });
1318
+ const msg = `auto-commit failed: ${String(e?.stderr || e?.message || e)}`;
1319
+ console.error(fail(msg, "commit your content manually (git add / git commit), or re-run with --no-commit"));
1320
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1321
+ return 1;
1322
+ }
1323
+ if (auto.committed) emitGitOp("commit", true, { command: verb });
1324
+ if (auto.inProgress) {
1325
+ const abortCmd = abortCommandFor(auto.inProgress);
1326
+ const msg = `a ${auto.inProgress} is still in progress here`;
664
1327
  console.error(
665
1328
  fail(
666
- `auto-commit failed: ${String(e?.stderr || e?.message || e)}`,
667
- "commit your content manually (git add / git commit), or re-run with --no-commit",
1329
+ msg,
1330
+ `finish it (resolve + continue) or back out (\`${abortCmd}\`), then re-run \`tot ${verb}\` — `
1331
+ + "auto-committing over an unresolved rebase/merge/cherry-pick would fold your half-resolved "
1332
+ + "tree into a plain content commit and can lose whatever edit was still unresolved",
668
1333
  ),
669
1334
  );
1335
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
670
1336
  return 1;
671
1337
  }
672
1338
  if (auto.refused) {
@@ -679,6 +1345,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
679
1345
  for (const p of auto.unknown) console.error(` ✗ out of scope: ${p}`);
680
1346
  console.error(`\n \`tot ${verb}\` auto-commits only: ${[...KNOWN_CONTENT_TREES, ...KNOWN_CONTENT_FILES].join(", ")}`);
681
1347
  if (auto.known.length) console.error(` (in scope, would have been committed: ${auto.known.join(", ")})`);
1348
+ emitJson(args, buildJsonResult({ ok: false, error: `${auto.unknown.length} change(s) outside the store content trees` }));
682
1349
  return 1;
683
1350
  }
684
1351
  if (auto.committed) {
@@ -690,12 +1357,22 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
690
1357
  // 1. validate locally — refuse on errors.
691
1358
  if (!args.skipValidate) {
692
1359
  const { ok, findings } = validateTenant(workspace, { tenantId: tenant, scope: ctx.config?.scope });
1360
+ // Advisory but LOUD: git conflict markers must never slip past as "validated"
1361
+ // (the half-resolved-rebase incident). Surfaced on the ok path too — warnings
1362
+ // are otherwise swallowed here — but they never block the submit.
1363
+ const conflicts = findings.filter((f) => f.rule === "git-conflict-markers");
1364
+ if (conflicts.length) {
1365
+ console.error(`\n⚠ git conflict markers in submitted content (${conflicts.length} file(s)) — an unfinished merge/rebase?`);
1366
+ for (const f of conflicts) console.error(` ⚠ ${f.file} — ${f.message}`);
1367
+ console.error(" The preview will still build, but it will serve the broken markers. Resolve before shipping.\n");
1368
+ }
693
1369
  if (!ok) {
694
1370
  const errs = findings.filter((f) => f.level === ERROR);
695
1371
  console.error(
696
1372
  fail(`${errs.length} validation error(s)`, "fix these (below), or re-run with --skip-validate") + "\n",
697
1373
  );
698
1374
  for (const f of errs) console.error(` ✗ [${f.rule}] ${f.file} — ${f.message}`);
1375
+ emitJson(args, buildJsonResult({ ok: false, error: `${errs.length} validation error(s)` }));
699
1376
  return 1;
700
1377
  }
701
1378
  console.error("~ validated (no errors)");
@@ -706,7 +1383,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
706
1383
  try {
707
1384
  commit = git(["rev-parse", "HEAD"]).trim();
708
1385
  } catch {
709
- console.error(fail("no commits here yet", "git add <files> && git commit -m '…', then re-run"));
1386
+ const msg = "no commits here yet";
1387
+ console.error(fail(msg, "git add <files> && git commit -m '…', then re-run"));
1388
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
710
1389
  return 1;
711
1390
  }
712
1391
  const short = commit.slice(0, 9);
@@ -730,12 +1409,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
730
1409
  };
731
1410
  function buildSummaryAndPatch(ref) {
732
1411
  const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
733
- const trackingRef = `refs/remotes/origin/${ref}`;
734
- const base = gitSafe(["rev-parse", "--verify", "--quiet", trackingRef]).trim()
735
- ? trackingRef
736
- : gitSafe(["rev-parse", "--verify", "--quiet", "HEAD~1"]).trim()
737
- ? "HEAD~1"
738
- : "";
1412
+ // Diff against the fork point off `preview` — the FULL branch delta — so the patch
1413
+ // is complete for candidate_open's server-cut (which resets the branch to preview
1414
+ // and re-applies this patch). NOT the candidate's own tracking ref: after a push
1415
+ // that landed but failed to open its PR, that ref equals HEAD → empty patch →
1416
+ // wrong "no file changes" → no PR. See resolvePatchBase.
1417
+ const base = resolvePatchBase(gitSafe, ref);
739
1418
  const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
740
1419
  const patchEntries = parseNameStatus(gitSafe(statusCmd));
741
1420
  const files = patchEntries.map((e) => e.path);
@@ -758,7 +1437,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
758
1437
  // needed — so they're available even on the no-session fallback path below.
759
1438
  const branch = currentBranch(gitSafe);
760
1439
  const statePath = defaultCandidateStatePath(env);
761
- const active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
1440
+ let active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
762
1441
 
763
1442
  let session;
764
1443
  try {
@@ -770,39 +1449,75 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
770
1449
  // still lands if that embedded token is live. actorKeyFor(null) degrades to the
771
1450
  // generic "developer" key — still isolated PER BRANCH (never the shared ref),
772
1451
  // just not per-developer until sign-in succeeds.
773
- const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, isNew: args.new });
1452
+ const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, forkCandidate: args.forkCandidate });
774
1453
  const ref = resolvePushRef({ ref: args.ref, changeId });
775
1454
  const { changeSummary } = buildSummaryAndPatch(ref);
776
1455
  console.error(`~ pushing ${short} → ${ref} (origin)`);
777
1456
  try {
778
1457
  const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
779
1458
  if (out.trim()) console.error(redactUrl(out.trim()));
1459
+ emitGitOp("push", true, { command: verb });
780
1460
  } catch (pushErr) {
781
- console.error(
782
- fail(
783
- `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`,
784
- "check your commit and that the checkout's remote is reachable, then re-run",
785
- ),
786
- );
1461
+ emitGitOp("push", false, {
1462
+ command: verb,
1463
+ errorClass: isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr) ? "forge_auth" : "git_push_failed",
1464
+ });
1465
+ const msg = `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`;
1466
+ // This is the NO-SESSION path pushing the clone-time embedded credential —
1467
+ // which rotation kills the moment any fresh mint happens elsewhere. An auth
1468
+ // failure here is therefore almost always "you're not signed in IN THIS
1469
+ // SHELL", not a network problem; the old remote-is-reachable hint sent a
1470
+ // human down the wrong path live (Trello-13075 polish).
1471
+ const hint = isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr)
1472
+ ? "you're not signed in in this shell, and the checkout's embedded credential has likely been rotated — run `tot login` (check TOT_PROFILE if you use per-terminal identities), then re-run"
1473
+ : "check your commit and that the checkout's remote is reachable, then re-run";
1474
+ console.error(fail(msg, hint));
1475
+ emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
787
1476
  return 1;
788
1477
  }
789
- console.log(`\n+ submitted ${short} to ${ref}.`);
790
- printChangeSummary(changeSummary);
791
- if (e instanceof AuthUnavailableError) {
792
- console.log(` (sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"})`);
793
- } else {
794
- console.log(` (couldn't reach Token of Trust for the result read-back: ${String(e?.message || e)})`);
1478
+ if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
1479
+ printChangeSummary(changeSummary, { quiet: args.json });
1480
+ const note = e instanceof AuthUnavailableError
1481
+ ? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
1482
+ : `couldn't reach Token of Trust for the result read-back: ${describeReadbackError(e)}`;
1483
+ if (!args.json) {
1484
+ console.log(` (${note})`);
1485
+ console.log(` Your push is in; the preview updates once reconcile completes.`);
795
1486
  }
796
- console.log(` Your push is in; the preview updates once reconcile completes.`);
1487
+ emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
797
1488
  return 0;
798
1489
  }
799
1490
 
1491
+ // u17 — before REUSING a remembered active pointer, confirm its PR is still open.
1492
+ // If it merged/closed we DROP it (and forget it on disk) so chooseChangeId falls
1493
+ // back to the stable id, rather than force-pushing onto a now-dead candidate branch
1494
+ // and opening a NEW PR that inherits a guaranteed conflict (the live incident this
1495
+ // guards against). Skipped under --fork-candidate (chooseChangeId ignores `active`
1496
+ // there anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
1497
+ // Needs the tenant scope bound for candidate_status to resolve — idempotent with
1498
+ // the later client_switch / the fresh-mint checkoutTenant.
1499
+ if (active && repo && !args.forkCandidate) {
1500
+ try {
1501
+ await client.callTool("client_switch", { tenant });
1502
+ } catch { /* scope bind is best-effort; candidateStateFor tolerates a miss */ }
1503
+ const resolved = await resolveActivePointer(client, { repo, active });
1504
+ if (resolved.dropped) {
1505
+ console.error(
1506
+ `~ remembered candidate ${resolved.dropped.changeId} is ${resolved.dropped.state} — dropping it and submitting fresh (a ${resolved.dropped.state} PR can't be reused).`,
1507
+ );
1508
+ try {
1509
+ clearActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch });
1510
+ } catch { /* best-effort local cleanup — a miss just re-checks next run */ }
1511
+ }
1512
+ active = resolved.active;
1513
+ }
1514
+
800
1515
  // Which candidate (and therefore which isolated ref, b03) this submit targets —
801
1516
  // decided now, with a real session, so the SAME id backs both the raw git push
802
1517
  // (right below) and the PR-backed candidate (step 2b): the two never point at
803
- // different branches. See chooseChangeId's doc for the --new / active-pointer
804
- // rules.
805
- let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, isNew: args.new });
1518
+ // different branches. See chooseChangeId's doc for the --fork-candidate /
1519
+ // active-pointer rules.
1520
+ let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, forkCandidate: args.forkCandidate });
806
1521
  const ref = resolvePushRef({ ref: args.ref, changeId });
807
1522
  const { changeSummary, patchEntries } = buildSummaryAndPatch(ref);
808
1523
 
@@ -829,17 +1544,19 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
829
1544
  try {
830
1545
  const { out } = await pushPreviewRef(git, mintRemote, { ref });
831
1546
  if (out && out.trim()) console.error(redactUrl(out.trim()));
1547
+ emitGitOp("push", true, { command: verb });
832
1548
  } catch (e) {
833
- console.error(
834
- fail(
835
- `push failed: ${redactUrl(String(e.stderr || e.message || e))}`,
836
- "check your commit and that the checkout's remote is reachable, then re-run",
837
- ),
838
- );
1549
+ emitGitOp("push", false, {
1550
+ command: verb,
1551
+ errorClass: isForgeAuthError(e?.stderr || e?.message || e) ? "forge_auth" : "git_push_failed",
1552
+ });
1553
+ const msg = `push failed: ${redactUrl(String(e.stderr || e.message || e))}`;
1554
+ console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
1555
+ emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
839
1556
  return 1;
840
1557
  }
841
- console.log(`\n+ submitted ${short} to ${ref}.`);
842
- printChangeSummary(changeSummary);
1558
+ if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
1559
+ printChangeSummary(changeSummary, { quiet: args.json });
843
1560
 
844
1561
  // 2b + 3. open/update the PR-backed candidate, then report reconcile +
845
1562
  // compliance + preview URL from the MCP — reusing the session established above.
@@ -849,23 +1566,94 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
849
1566
  // (idempotent — checkoutTenant already switched when the fresh mint succeeded).
850
1567
  await client.callTool("client_switch", { tenant });
851
1568
 
852
- // 2b. PR-backed candidate (g1b candidate_open, unit c1) best-effort: a
853
- // failure here (older MCP, VC not configured, preview-access capability) is
854
- // reported and swallowed, never blocking the preview push that already landed.
1569
+ // 2b. PR-backed candidate (g1b candidate_open, unit c1). Compatibility and
1570
+ // capability failures remain best-effort, but the MCP's audit/identity refusals
1571
+ // propagate and make this command fail: an unaudited PR is never an acceptable
1572
+ // successful submit.
855
1573
  // `changeId`/`stableId`/`persist` were already decided above (they picked the
856
1574
  // push ref too); if the chosen candidate turns out to be merged/closed, roll to
857
1575
  // a fresh one so a re-submit is never wedged on a dead PR. (`repo` was derived
858
1576
  // above.)
859
1577
  const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
860
1578
 
861
- let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
1579
+ let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
862
1580
 
863
1581
  if (candidate && isTerminalCandidateState(candidate.state)) {
864
1582
  const rolled = mintFreshChangeId(stableId);
865
- console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
1583
+ if (!args.json) console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
866
1584
  changeId = rolled;
867
1585
  persist = true;
868
- candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
1586
+ candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
1587
+ }
1588
+
1589
+ // c3 — BORN-REBASED at submit (shift-left prevention #2). The candidate is open;
1590
+ // if the base has drifted (the SAME signal c2 warned on, pre-push) rebuild it from
1591
+ // the CURRENT base tip via candidate_refresh BEFORE finalizing, so it enters the
1592
+ // queue already fresh instead of settling not-mergeable at Accept time.
1593
+ // • clean rebuild → report it, hand back the fresh candidate's shareable URL,
1594
+ // and finish (the rebuilt PR has a NEW head; polling the old
1595
+ // local `commit` would read as never-dispatched, so we skip
1596
+ // the reconcile poll and say the fresh preview is building);
1597
+ // • genuine overlap (merge_failed) → surface the resolve card right here, then
1598
+ // fall through — candidate_refresh made NO changes on a real
1599
+ // conflict, so the candidate's head still matches `commit`
1600
+ // and the normal reconcile poll below is still valid;
1601
+ // • anything else (owner-gated denial, older MCP, error) → submit as-is.
1602
+ // Best-effort throughout: candidate_refresh is app-owner gated, so an ordinary
1603
+ // invited developer's session may be denied — that degrades to submitting as-is,
1604
+ // never blocking the push that already landed. Gated on --skip-freshness via
1605
+ // baseDrift (0 when skipped).
1606
+ if (repo && baseDrift > 0 && candidate && !isTerminalCandidateState(candidate.state) && changeId) {
1607
+ const rebased = await runBornRebased(client, { repo, changeId, strategy: bornRebasedStrategy });
1608
+ if (rebased.ok) {
1609
+ for (const line of formatBornRebasedSuccess({ behind: baseDrift, strategy: rebased.strategy || bornRebasedStrategy, refreshedFiles: rebased.refreshedFiles })) {
1610
+ if (!args.json) console.log(line);
1611
+ }
1612
+ const freshPr = typeof rebased.prNumber === "number" ? rebased.prNumber : candidate.prNumber;
1613
+ const freshPrUrl = typeof freshPr === "number"
1614
+ ? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, freshPr)
1615
+ : null;
1616
+ if (freshPrUrl && !args.json) {
1617
+ console.log(`\n ▸ Your fresh preview will appear at:\n ${freshPrUrl}\n (building on the current store — this link goes live once reconcile completes)`);
1618
+ }
1619
+ // The rebuild keeps the STABLE changeId, so the persisted active pointer stays
1620
+ // valid — record it (best-effort) exactly as the normal open path does below.
1621
+ if (persist && repo) {
1622
+ try {
1623
+ writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
1624
+ } catch { /* best-effort local hint */ }
1625
+ }
1626
+ emitJson(args, buildJsonResult({
1627
+ ok: true, ref, commit, changeId,
1628
+ candidate: { ...candidate, prNumber: freshPr ?? candidate.prNumber },
1629
+ previewPrUrl: freshPrUrl,
1630
+ note: `born-rebased on the current base (${rebased.strategy || bornRebasedStrategy})`,
1631
+ }));
1632
+ return 0;
1633
+ }
1634
+ if (rebased.status === "merge_failed") {
1635
+ for (const line of formatBornRebasedConflict({ unresolved: rebased.unresolved }, verb)) {
1636
+ if (!args.json) console.log(line);
1637
+ }
1638
+ // fall through to the normal poll — the candidate is unchanged.
1639
+ } else if (rebased.message && !args.json) {
1640
+ console.log(` ~ couldn't auto-rebase on the current base (${rebased.message}) — submitting as-is.`);
1641
+ }
1642
+ }
1643
+
1644
+ // Immediate shareable URL (Vercel-style: "the URL exists before the build
1645
+ // does"). A non-terminal candidate with a real PR number means a preview
1646
+ // WILL be built at a deterministic route — so hand the developer that link
1647
+ // right now, before reconcile even starts, rather than making them wait for
1648
+ // the server-minted `previewUrl` (formatShareableUrlBlock) that only shows
1649
+ // up once reconcile actually completes. Honest framing: it's printed as
1650
+ // "building", never as "ready".
1651
+ const previewPrUrl =
1652
+ candidate && !isTerminalCandidateState(candidate.state) && typeof candidate.prNumber === "number"
1653
+ ? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, candidate.prNumber)
1654
+ : null;
1655
+ if (previewPrUrl && !args.json) {
1656
+ console.log(`\n ▸ Your preview will appear at:\n ${previewPrUrl}\n (building — this link goes live once reconcile completes)`);
869
1657
  }
870
1658
 
871
1659
  // Remember the active candidate only on a real, non-terminal open (best-effort;
@@ -885,35 +1673,57 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
885
1673
  // as live, not as a growing wall of "(1)…(8)" lines. The counter keeps
886
1674
  // ticking on its own 90ms timer even while a single poll long-polls for
887
1675
  // waitMs, so elapsed time is real wall-clock, not the attempt count.
1676
+ // --json is for automation: no interactive spinner (stays silent — the
1677
+ // JSON result carries the same status at the end).
1678
+ // Honest opening label (the incident this fixes: a job may never actually
1679
+ // get dispatched — see pollPreviewStatus's notDispatched short-circuit — so
1680
+ // the INITIAL text must not assert a reconcile job exists before one has
1681
+ // been observed). Once a tick confirms `s.dispatched === true` the 45s
1682
+ // stage text ("still reconciling…") IS truthful and is left as-is below.
888
1683
  let phase = "reconcile";
889
- progress = startProgress(`reconcile running for ${short}…`, {
890
- stages: [{ afterMs: 45_000, text: `still reconciling ${short} (larger changes take longer)` }],
891
- });
1684
+ if (!args.json) {
1685
+ progress = startProgress(`waiting for reconcile of ${short}…`, {
1686
+ stages: [{ afterMs: 45_000, text: `still reconciling ${short}… (larger changes take longer)` }],
1687
+ });
1688
+ }
892
1689
  status = await pollPreviewStatus(client, commit, {
893
1690
  ...(args.watch ? WATCH_POLL : DEFAULT_POLL),
894
1691
  onTick: (s) => {
895
1692
  // Reconcile is done but we're still waiting on a ship decision (--watch):
896
1693
  // swap the label so the single line reflects the new phase, timer resets.
897
- if (s.status === "reconciled" && !s.shipped && phase !== "ship") {
1694
+ if (!args.json && s.status === "reconciled" && !s.shipped && phase !== "ship") {
898
1695
  phase = "ship";
899
1696
  progress.stop();
900
1697
  progress = startProgress(`reconciled ${short} — waiting for a ship decision…`);
901
1698
  }
902
1699
  },
903
1700
  });
904
- progress.stop();
905
- progress = null;
1701
+ if (progress) {
1702
+ progress.stop();
1703
+ progress = null;
1704
+ }
906
1705
  }
907
- reportStatus(status, tenant, { open: !args.noOpen });
1706
+ // --json also skips the browser auto-open (open: !args.noOpen && !args.json)
1707
+ // — automation doesn't want a browser popping up.
1708
+ reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit, ref, verb, noChanges: patchEntries.length === 0 });
1709
+ emitJson(args, buildJsonResult({ ok: status?.status !== "failed", ref, commit, changeId, candidate, status, previewPrUrl }));
908
1710
  return status?.status === "failed" ? 1 : 0;
909
1711
  } catch (e) {
910
1712
  progress?.stop();
911
- if (e instanceof AuthUnavailableError) {
912
- console.log(` (sign in to see the reconcile/compliance/preview result ${e.hint || "developer sign-in pending"})`);
913
- } else {
914
- console.log(` (reconcile is running the result read-back isn't available yet: ${String(e?.message || e)})`);
1713
+ if (e instanceof CandidateAttributionError) {
1714
+ const note = `${e.message} Your preview push is in, but no review PR was created.`;
1715
+ if (!args.json) console.error(fail(note, "follow the identity guidance above, then re-run `tot submit`"));
1716
+ emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
1717
+ return 1;
1718
+ }
1719
+ const note = e instanceof AuthUnavailableError
1720
+ ? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
1721
+ : `reconcile is running — the result read-back isn't available yet: ${describeReadbackError(e)}`;
1722
+ if (!args.json) {
1723
+ console.log(` (${note})`);
1724
+ console.log(` Your push is in; the preview updates once reconcile completes.`);
915
1725
  }
916
- console.log(` Your push is in; the preview updates once reconcile completes.`);
1726
+ emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
917
1727
  return 0;
918
1728
  }
919
1729
  }
@@ -921,20 +1731,31 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
921
1731
  /**
922
1732
  * Normalize a `preview_status` tool response to the contract shape the CLI reports
923
1733
  * on: { status, reconcile:{ok,errors}, compliance:{verdict,detail}, previewUrl,
924
- * shipped }. A live tool always returns status pending|reconciled|failed; anything
925
- * else (an older MCP without the tool's flat fields) normalizes to "unknown" so the
926
- * CLI degrades visibly instead of pretending it reconciled. `shipped` (E1b) is
927
- * null until change_accept ships this exact commit. Pure — unit-tested.
1734
+ * shipped, delivery, dispatched }. A live tool always returns status
1735
+ * pending|reconciled|failed; anything else (an older MCP without the tool's flat
1736
+ * fields) normalizes to "unknown" so the CLI degrades visibly instead of pretending
1737
+ * it reconciled. `shipped` (E1b) is null until change_accept ships this exact commit.
1738
+ *
1739
+ * `delivery` is the MCP's reconcile-observability triad for the Gitea webhook that
1740
+ * fired on this commit ({ target, actual, evidence, drift? }) — or null when NO
1741
+ * webhook delivery was observed for this (tenant, commit). `dispatched` distills that
1742
+ * to a boolean: a `pending` status with `dispatched === false` means the reconcile
1743
+ * was NEVER DISPATCHED (no webhook fired — e.g. an unregistered hook, or a commit
1744
+ * read under a different tenant scope than it was pushed to), which is a permanent
1745
+ * dead-end the CLI must not report as "still reconciling". Pure — unit-tested.
928
1746
  */
929
1747
  export function normalizePreviewStatus(r) {
930
1748
  const status = r?.status;
931
1749
  const known = status === "pending" || status === "reconciled" || status === "failed";
1750
+ const delivery = r?.delivery ?? null;
932
1751
  return {
933
1752
  status: known ? status : "unknown",
934
1753
  reconcile: r?.reconcile ?? null,
935
1754
  compliance: r?.compliance ?? null,
936
1755
  previewUrl: r?.previewUrl ?? null,
937
1756
  shipped: r?.shipped ?? null,
1757
+ delivery,
1758
+ dispatched: delivery != null,
938
1759
  raw: r,
939
1760
  };
940
1761
  }
@@ -951,29 +1772,79 @@ export function normalizePreviewStatus(r) {
951
1772
  * the pre-E2 fixed-interval poll — no version check needed, the fallback is
952
1773
  * automatic. Stops as soon as status resolves to "failed"/"unknown" (a failed
953
1774
  * reconcile can't ship), or resolves to "reconciled" AND (not untilShipped, or
954
- * already shipped). Injectable delay/attempts/waitMs for tests.
1775
+ * already shipped).
1776
+ *
1777
+ * NEVER-DISPATCHED short-circuit (the "still reconciling forever" fix): a `pending`
1778
+ * status with NO delivery ever observed for this commit means no reconcile job was
1779
+ * ever dispatched (unregistered webhook, or a tenant-scope mismatch on the read) —
1780
+ * it will never resolve. Rather than walk the whole (~8 min under --watch) budget
1781
+ * lying about progress, once we're past a short startup grace (`notDispatchedGraceMs`
1782
+ * — long enough for a real delivery record to land after the push) with the delivery
1783
+ * still absent, we stop and return the honest state tagged `notDispatched: true`. If
1784
+ * a delivery IS seen we keep polling as before (dispatched, just slow), and a plain
1785
+ * timeout while still pending is tagged `notDispatched` only when a delivery was never
1786
+ * observed. Injectable delay/attempts/waitMs/grace + a `now` clock and `sleep`
1787
+ * fn for tests (so the fallback-sleep cadence can be asserted deterministically
1788
+ * off the injected clock instead of real wall-clock elapsed time).
955
1789
  * @param {{callTool:Function}} client
956
1790
  * @param {string} commit
957
- * @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean, onTick?: (s:object,i:number)=>void }} [opts]
1791
+ * @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean,
1792
+ * notDispatchedGraceMs?: number, now?: () => number, sleep?: (ms:number) => Promise<void>,
1793
+ * onTick?: (s:object,i:number)=>void }} [opts]
958
1794
  */
959
1795
  export async function pollPreviewStatus(
960
1796
  client,
961
1797
  commit,
962
- { attempts = 8, delayMs = 2500, waitMs = delayMs, untilShipped = false, onTick } = {},
1798
+ {
1799
+ attempts = 8,
1800
+ delayMs = 2500,
1801
+ waitMs = delayMs,
1802
+ untilShipped = false,
1803
+ notDispatchedGraceMs = 15_000,
1804
+ now = Date.now,
1805
+ sleep = delay,
1806
+ onTick,
1807
+ } = {},
963
1808
  ) {
964
1809
  let last = null;
1810
+ let everDispatched = false;
1811
+ const pollStart = now();
965
1812
  for (let i = 0; i < attempts; i++) {
966
- const startedAt = Date.now();
1813
+ const startedAt = now();
967
1814
  const args = waitMs ? { commit, waitMs } : { commit };
968
1815
  last = normalizePreviewStatus(await client.callTool("preview_status", args));
1816
+ if (last.dispatched) everDispatched = true;
1817
+ last.everDispatched = everDispatched;
969
1818
  if (onTick) onTick(last, i);
1819
+ // Terminally-failed forward: the delivery record settled with forwarded:false
1820
+ // (and it isn't the at-receipt `pending` marker) — the control plane could not
1821
+ // deliver this commit to the reconciler, and polling longer cannot change that.
1822
+ // Only a NEW push produces a new delivery. Stop and say so (Trello-13075
1823
+ // honesty discipline: never spin on a state that cannot progress).
1824
+ if (
1825
+ last.status === "pending" &&
1826
+ last.delivery?.actual &&
1827
+ last.delivery.actual.forwarded === false &&
1828
+ !last.delivery.actual.pending
1829
+ ) {
1830
+ return { ...last, forwardFailed: true };
1831
+ }
1832
+ // Never-dispatched dead-end: still pending, no delivery has EVER been observed
1833
+ // for this commit, and we're past the startup grace — the reconcile will never
1834
+ // arrive. Return honestly instead of continuing to show "still reconciling".
1835
+ if (last.status === "pending" && !everDispatched && now() - pollStart >= notDispatchedGraceMs) {
1836
+ return { ...last, notDispatched: true };
1837
+ }
970
1838
  const stillWatchingForShip = untilShipped && last.status === "reconciled" && !last.shipped;
971
1839
  if (last.status !== "pending" && !stillWatchingForShip) return last;
972
1840
  if (i < attempts - 1) {
973
- const remaining = delayMs - (Date.now() - startedAt);
974
- if (remaining > 0) await delay(remaining);
1841
+ const remaining = delayMs - (now() - startedAt);
1842
+ if (remaining > 0) await sleep(remaining);
975
1843
  }
976
1844
  }
1845
+ // Budget exhausted. A still-pending result that never saw a delivery is a
1846
+ // never-dispatched dead-end (honest), not "still working".
1847
+ if (last) return { ...last, everDispatched, notDispatched: last.status === "pending" && !everDispatched };
977
1848
  return last;
978
1849
  }
979
1850
 
@@ -993,6 +1864,58 @@ function reportComplianceCheck(c) {
993
1864
  if (c.hint) console.log(` → fix: ${c.hint}`);
994
1865
  }
995
1866
 
1867
+ /**
1868
+ * The IMMEDIATE shareable preview URL (Vercel-style: "the URL exists before the
1869
+ * build does") — composed client-side, deterministically, from the tenant + PR
1870
+ * number the candidate_open call just returned, so a developer gets a link to
1871
+ * paste to a reviewer the INSTANT the candidate opens, not minutes later once
1872
+ * reconcile finishes and the MCP mints `previewUrl` server-side (that's
1873
+ * formatShareableUrlBlock's job, above — the two are deliberately redundant:
1874
+ * this one is available immediately but "building", that one is authoritative
1875
+ * once reconcile actually lands). Same route shape as the server-minted one
1876
+ * (`/preview/<tenant>/pr/<N>`) by construction — see
1877
+ * docs/architecture/preview-candidate-workflow.md — so the link doesn't change
1878
+ * out from under the reviewer once the build completes; it just starts
1879
+ * resolving.
1880
+ * Trims a trailing slash off `base` so a `TOT_STOREFRONT_URL` set WITH or
1881
+ * without one composes identically. Pure — unit-tested.
1882
+ * @param {string} base storefront origin, e.g. https://storefront.tokenoftrust.store
1883
+ * @param {string} tenant
1884
+ * @param {number} prNumber
1885
+ * @returns {string}
1886
+ */
1887
+ export function shareablePrUrl(base, tenant, prNumber) {
1888
+ return `${String(base).replace(/\/+$/, "")}/preview/${tenant}/pr/${prNumber}`;
1889
+ }
1890
+
1891
+ /**
1892
+ * Humanize a result-read-back failure. MCP auth errors arrive as a JSON blob
1893
+ * whose `data.self_repair` carries a summary + step list — dumping that raw
1894
+ * into the terminal (observed live: a wall of escaped JSON mid-submit) buries
1895
+ * the one thing the developer needs: sign in again. Detect that shape and
1896
+ * reduce it to the summary's first sentence + the concrete next step; anything
1897
+ * else passes through unchanged. Pure — unit-tested.
1898
+ * @param {unknown} e
1899
+ * @returns {string}
1900
+ */
1901
+ export function describeReadbackError(e) {
1902
+ const msg = String(/** @type {any} */ (e)?.message || e || "");
1903
+ const jsonStart = msg.indexOf("{");
1904
+ if (jsonStart >= 0 && msg.includes("self_repair")) {
1905
+ try {
1906
+ const body = JSON.parse(msg.slice(jsonStart));
1907
+ const repair = body?.data?.self_repair;
1908
+ const summaryFirst = String(repair?.summary || body?.message || "").split(/(?<=\.)\s/)[0];
1909
+ if (summaryFirst) {
1910
+ return `${summaryFirst} Next: run \`tot login\` in this shell (check TOT_PROFILE), then re-run.`;
1911
+ }
1912
+ } catch {
1913
+ // Not the shape we thought — fall through to the raw message.
1914
+ }
1915
+ }
1916
+ return msg;
1917
+ }
1918
+
996
1919
  /**
997
1920
  * Build the printed lines for the headline "share this with your reviewer" block —
998
1921
  * the whole point of U14: on a successful preview, the SHAREABLE deep link
@@ -1025,41 +1948,132 @@ export function formatShareableUrlBlock(s, tenant) {
1025
1948
  return [];
1026
1949
  }
1027
1950
 
1951
+ /**
1952
+ * The honest "no reconcile job was dispatched" block — printed when a preview stays
1953
+ * `pending` with no webhook delivery ever observed for the commit (pollPreviewStatus
1954
+ * tagged it `notDispatched`). This replaces the old "reconcile still running — check
1955
+ * back / re-submit" lie for the dead-end case: re-submitting cannot help, so we say
1956
+ * what actually happened and what to do, and never recommend another submit. Pure —
1957
+ * unit-tested. `verb` brands the copy with whatever the developer typed.
1958
+ * @param {{ commit?: string|null, ref?: string|null, noChanges?: boolean }} ctx
1959
+ * @param {string} tenant @param {string} [verb]
1960
+ * @returns {string[]}
1961
+ */
1962
+ export function formatNotDispatchedBlock({ commit = null, ref = null, noChanges = false } = {}, tenant, verb = "preview") {
1963
+ const short = commit ? commit.slice(0, 9) : "(unknown commit)";
1964
+ // Empty-diff cause FIRST when we know it applies (live-testing finding: an
1965
+ // empty candidate submit structurally CANNOT build — no diff → no candidate PR
1966
+ // → no pull_request webhook — and blaming webhooks/scope for it sent a human
1967
+ // down two wrong debugging paths).
1968
+ const causes = [
1969
+ ...(noChanges
1970
+ ? [` • your submit contained NO content changes — a candidate with no diff opens no PR and builds nothing (make an edit, or move the shared ref: \`tot ${verb} --ref preview\`),`]
1971
+ : []),
1972
+ ` • the store's reconcile webhook isn't registered yet (an operator must (re-)provision it), or`,
1973
+ ` • your session is scoped to a different store than the one you pushed.`,
1974
+ ];
1975
+ return [
1976
+ `\n ⚠ No reconcile was dispatched for ${short} on ${tenant}.`,
1977
+ ` Your push landed${ref ? ` on ${ref}` : ""}, but nothing picked it up to build a preview —`,
1978
+ ` re-running \`tot ${verb}\` will NOT change that. This usually means one of:`,
1979
+ ...causes,
1980
+ ` Next:`,
1981
+ ` • \`tot grants\` — confirm ${tenant} is active for you;`,
1982
+ ` • check the preview dashboard for ${tenant} (it will read "Last reconcile: never" until a job runs);`,
1983
+ ` • if it stays "never", share this with support: commit ${short}, tenant ${tenant}${ref ? `, ref ${ref}` : ""}.`,
1984
+ ];
1985
+ }
1986
+
1987
+ /**
1988
+ * The honest "the delivery FAILED to forward" block — the delivery record settled
1989
+ * `forwarded: false` (not the at-receipt pending marker), so the control plane
1990
+ * could not deliver this commit to the reconciler; polling longer cannot change
1991
+ * that, and ONLY a new push produces a new delivery. Distinct from
1992
+ * `formatNotDispatchedBlock` (nothing was ever dispatched) — here the plumbing
1993
+ * fired and died in transit, so the recovery differs. Pure — unit-tested.
1994
+ * @param {{ commit?: string|null }} ctx @param {string} tenant
1995
+ * @returns {string[]}
1996
+ */
1997
+ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
1998
+ const short = commit ? commit.slice(0, 9) : "(unknown commit)";
1999
+ return [
2000
+ `\n ⚠ The reconcile delivery for ${short} on ${tenant} FAILED in transit (network/timeout at the control plane).`,
2001
+ ` Waiting longer will not help — only a NEW push produces a new delivery.`,
2002
+ ` Next: commit again (an empty commit works: git commit --allow-empty -m retry) and re-push;`,
2003
+ ` if it fails the same way twice, share this with support: commit ${short}, tenant ${tenant}.`,
2004
+ ];
2005
+ }
2006
+
1028
2007
  /**
1029
2008
  * Print the reconcile/compliance/preview result and, on a clean reconcile with a
1030
- * preview URL, open it in the browser (unless opts.open === false).
2009
+ * preview URL, open it in the browser (unless opts.open === false). `quiet`
2010
+ * (--json) suppresses ALL printing here — the browser open still runs unless
2011
+ * the caller also passes `open: false` (run() passes `open: false` under
2012
+ * --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
2013
+ * feed the honest never-dispatched block.
2014
+ */
2015
+ /**
2016
+ * @param {any} s @param {string} tenant
2017
+ * @param {{ open?: boolean, quiet?: boolean, commit?: string|null, ref?: string|null, verb?: string, noChanges?: boolean }} [opts]
1031
2018
  */
1032
- function reportStatus(s, tenant, { open = true } = {}) {
2019
+ function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview", noChanges = false } = {}) {
1033
2020
  if (!s || s.status === "unknown") {
1034
- console.log(
1035
- ` (this MCP doesn't return the per-commit reconcile result yet — your push is in;\n` +
1036
- ` the preview updates once reconcile runs. Check the preview dashboard.)`,
1037
- );
2021
+ if (!quiet) {
2022
+ console.log(
2023
+ ` (this MCP doesn't return the per-commit reconcile result yet your push is in;\n` +
2024
+ ` the preview updates once reconcile runs. Check the preview dashboard.)`,
2025
+ );
2026
+ }
1038
2027
  return;
1039
2028
  }
1040
- if (s.status === "pending") {
1041
- console.log(` reconcile still running for ${tenant} check back shortly (re-run \`tot submit --no-wait\`).`);
2029
+ // Terminally-failed forward the delivery fired and died in transit; a re-push
2030
+ // (new delivery) is the only recovery. Checked BEFORE notDispatched: a settled
2031
+ // failed forward IS a dispatch, just a doomed one.
2032
+ if (s.forwardFailed) {
2033
+ if (!quiet) for (const line of formatForwardFailedBlock({ commit }, tenant)) console.log(line);
1042
2034
  return;
1043
2035
  }
1044
- const rc = s.reconcile;
1045
- if (rc) {
1046
- if (rc.ok) console.log(` ✓ reconcile ok`);
1047
- else {
1048
- console.log(` ✗ reconcile failed:`);
1049
- for (const e of rc.errors ?? []) console.log(` [${e.rule ?? "?"}] ${e.message ?? ""}`);
1050
- }
2036
+ // Never-dispatched dead-end — the honest replacement for false "still reconciling".
2037
+ if (s.notDispatched) {
2038
+ if (!quiet) for (const line of formatNotDispatchedBlock({ commit, ref, noChanges }, tenant, verb)) console.log(line);
2039
+ return;
1051
2040
  }
1052
- if (s.compliance?.verdict) {
1053
- console.log(` compliance: ${s.compliance.verdict}${s.compliance.detail ? ` — ${s.compliance.detail}` : ""}`);
1054
- for (const c of s.compliance.checks ?? []) reportComplianceCheck(c);
2041
+ if (s.status === "pending") {
2042
+ if (!quiet) {
2043
+ // Dispatched but not yet reported (real slow reconcile) vs. no job seen yet on
2044
+ // a --no-wait snapshot — say which, and never claim progress we can't see.
2045
+ if (s.dispatched === false) {
2046
+ console.log(` no reconcile job seen yet for ${tenant} — if it doesn't appear shortly, run \`tot grants\` / check the dashboard.`);
2047
+ } else {
2048
+ console.log(` reconcile still running for ${tenant} — check back shortly (re-run \`tot submit --no-wait\`).`);
2049
+ if (s.delivery?.drift) {
2050
+ console.log(` ⚠ a reconcile report exists for a DIFFERENT commit than you pushed — possible tenant-scope mismatch (\`tot grants\` to check your active store).`);
2051
+ }
2052
+ }
2053
+ }
2054
+ return;
1055
2055
  }
1056
- if (s.shipped) {
1057
- console.log(`\n ✓ shipped change ${s.shipped.changeId} accepted at ${s.shipped.shippedAt}`);
1058
- } else if (s.status === "reconciled") {
1059
- console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
2056
+ if (!quiet) {
2057
+ const rc = s.reconcile;
2058
+ if (rc) {
2059
+ if (rc.ok) console.log(` reconcile ok`);
2060
+ else {
2061
+ console.log(` ✗ reconcile failed:`);
2062
+ for (const e of rc.errors ?? []) console.log(` [${e.rule ?? "?"}] ${e.message ?? ""}`);
2063
+ }
2064
+ }
2065
+ if (s.compliance?.verdict) {
2066
+ console.log(` compliance: ${s.compliance.verdict}${s.compliance.detail ? ` — ${s.compliance.detail}` : ""}`);
2067
+ for (const c of s.compliance.checks ?? []) reportComplianceCheck(c);
2068
+ }
2069
+ if (s.shipped) {
2070
+ console.log(`\n ✓ shipped — change ${s.shipped.changeId} accepted at ${s.shipped.shippedAt}`);
2071
+ } else if (s.status === "reconciled") {
2072
+ console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
2073
+ }
2074
+ for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
1060
2075
  }
1061
- for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
1062
2076
  if (s.previewUrl && open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
1063
- console.log(" (opened in your browser)");
2077
+ if (!quiet) console.log(" (opened in your browser)");
1064
2078
  }
1065
2079
  }