@tokenoftrust/cli 1.4.0-rc.9 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * From inside a tenant checkout:
5
5
  * 1. validate locally and refuse on errors (fail fast before anything leaves the machine),
6
- * 2. push your committed work to the tenant repo's `preview` ref (triggers reconcile),
6
+ * 2. push your committed work to YOUR OWN isolated candidate ref `candidate/<changeId>`
7
+ * (b03 — no shared `preview` ref is ever force-pushed; an explicit `--ref` can still
8
+ * target a literal ref name for back-compat) — which triggers reconcile,
7
9
  * 2b. open/update a PR-BACKED CANDIDATE for the same committed diff (g1b's
8
10
  * `candidate_open`, unit c1 — the local-dev-loop half of the "PR-Backed Hosted
9
11
  * Review Loop" milestone, symmetric with the hosted s6 draft-as-PR path), and
@@ -38,27 +40,72 @@
38
40
  * Dependency-free (global fetch + `git`).
39
41
  */
40
42
  import { execFileSync } from "node:child_process";
43
+ import { readFileSync, existsSync } from "node:fs";
44
+ import { resolve as resolvePath } from "node:path";
41
45
  import { createHash } from "node:crypto";
42
46
  import { setTimeout as delay } from "node:timers/promises";
43
47
  import { createMcpClient } from "../mcp.mjs";
44
48
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
49
+ import { checkoutTenant } from "./clone.mjs";
45
50
  import { validateTenant, ERROR } from "../validate.mjs";
46
51
  import { openBrowser } from "../open.mjs";
47
52
  import { startProgress } from "../progress.mjs";
48
53
  import { fail } from "../errors.mjs";
54
+ import { emitActivity } from "../activity.mjs";
49
55
  import {
50
56
  defaultCandidateStatePath,
51
57
  readActiveChangeId,
52
58
  writeActiveChangeId,
59
+ clearActiveChangeId,
53
60
  mintFreshChangeId,
54
61
  isTerminalCandidateState,
62
+ isDefaultBranch,
55
63
  } from "../candidate-state.mjs";
56
64
 
57
65
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
58
- const DEFAULT_REF = "preview";
66
+
67
+ // The public storefront origin a shareable preview PR URL is composed against
68
+ // (see shareablePrUrl, below) — the SAME default `tot ship` uses (ship.mjs's
69
+ // DEFAULT_STOREFRONT_URL), so a preview link and a ship link always agree on
70
+ // which storefront they point at even though submit.mjs and ship.mjs never
71
+ // import from each other.
72
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
73
+
74
+ // The git ref prefix an isolated candidate push lands under (b03 — stop
75
+ // force-pushing the SHARED `preview` ref). ONE constant so a rename is trivial —
76
+ // provisionally coordinated with the MCP-side candidate_open resolution (b02/b04),
77
+ // which already names its PR-backed branch `candidate/<changeId>` (see
78
+ // submitCandidate, below): the raw git push here and the PR-backed candidate it
79
+ // opens always target the SAME branch, never two.
80
+ export const CANDIDATE_REF_PREFIX = "candidate/";
81
+
82
+ /** The isolated git ref a candidate's preview push lands under. Pure. */
83
+ export function candidateRefFor(changeId) {
84
+ return `${CANDIDATE_REF_PREFIX}${changeId}`;
85
+ }
86
+
87
+ /**
88
+ * D3: emit a git-op lifecycle event for `tot submit`/`tot preview` — one per git
89
+ * operation (commit / push), carrying its own success/failure, so the timeline sees
90
+ * the individual git steps, not just the outer command's invoked/result pair. Uses
91
+ * the `cli.command.result` catalog key with a `git.<op>` subcommand (a fixed, safe
92
+ * value). Fire-and-forget best-effort: a silent no-op without a hosted-bridge
93
+ * credential, never awaited, never throws, never alters the command. `errorClass` is
94
+ * a low-cardinality class (never a raw git stderr, which can carry a token/path).
95
+ */
96
+ function emitGitOp(op, ok, { command = "submit", durationMs, errorClass } = {}) {
97
+ void emitActivity({
98
+ action: "cli.command.result",
99
+ outcome: { status: ok ? "succeeded" : "failed", ...(durationMs != null ? { durationMs } : {}), ...(errorClass ? { errorClass } : {}) },
100
+ payload: { args: { command, subcommand: `git.${op}`, ...(durationMs != null ? { durationMs } : {}) } },
101
+ });
102
+ }
59
103
 
60
104
  export function parseArgs(argv) {
61
- const a = { mcp: null, identity: null, ref: DEFAULT_REF, skipValidate: false, noWait: false, watch: false, noOpen: false, message: null, summary: null, new: false, help: false };
105
+ // `ref: null` an explicit `--ref` always wins; otherwise the push target is
106
+ // derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
107
+ // never a fixed shared default.
108
+ const a = { mcp: null, identity: null, ref: null, skipValidate: false, skipFreshness: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, summaryFile: null, json: false, new: false, help: false };
62
109
  for (let i = 0; i < argv.length; i++) {
63
110
  const t = argv[i];
64
111
  if (t === "--mcp") a.mcp = argv[++i];
@@ -66,7 +113,11 @@ export function parseArgs(argv) {
66
113
  else if (t === "--ref") a.ref = argv[++i];
67
114
  else if (t === "-m" || t === "--message") a.message = argv[++i];
68
115
  else if (t === "--summary") a.summary = argv[++i];
116
+ else if (t === "--summary-file") a.summaryFile = argv[++i];
117
+ else if (t === "--json") a.json = true;
69
118
  else if (t === "--skip-validate") a.skipValidate = true;
119
+ else if (t === "--skip-freshness") a.skipFreshness = true;
120
+ else if (t === "--no-commit") a.noCommit = true;
70
121
  else if (t === "--no-wait") a.noWait = true;
71
122
  else if (t === "--watch") a.watch = true;
72
123
  else if (t === "--no-open") a.noOpen = true;
@@ -76,27 +127,51 @@ export function parseArgs(argv) {
76
127
  return a;
77
128
  }
78
129
 
79
- const USAGE = `tot submit — submit your store for preview
130
+ /**
131
+ * Render the usage block for whichever verb invoked this flow. `preview` is the
132
+ * first-class verb; `submit`/`deploy` reach the same flow as teaching aliases, so
133
+ * the help they print names the verb the developer actually typed (see preview.mjs).
134
+ * @param {string} [verb]
135
+ */
136
+ export function renderUsage(verb = "preview") {
137
+ return `tot ${verb} — submit your store for preview
80
138
 
81
- tot submit validate → push the preview ref → stream the result
82
- tot submit --new open a NEW candidate PR instead of updating your open one
83
- tot submit --watch stay attached through reconcile + compliance + accept (long-poll)
84
- tot submit --skip-validate push without the local lint (not recommended)
85
- tot submit --ref <name> push ref (default: ${DEFAULT_REF})
86
- tot submit -m "<title>" one-line summary of what changed (the approver sees this)
87
- tot submit --summary "<text>" longer description to accompany the title
88
- tot submit --no-wait push and exit without polling for the reconcile result
89
- tot submit --no-open don't open the preview URL in the browser on success
90
- tot submit --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
139
+ tot ${verb} validate → push the preview ref → stream the result
140
+ tot ${verb} --new open a NEW candidate PR instead of updating your open one
141
+ tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
142
+ tot ${verb} --skip-validate push without the local lint (not recommended)
143
+ tot ${verb} --skip-freshness skip the stale-base check (not recommended — may build a
144
+ candidate rooted in an already-superseded base)
145
+ tot ${verb} --no-commit don't auto-commit a dirty tree preview only what's already committed
146
+ tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
147
+ tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
148
+ tot ${verb} --summary "<text>" longer description to accompany the title
149
+ tot ${verb} --summary-file <path> structured summary from a file — JSON
150
+ {intent,effect,verification,risk} or the labeled
151
+ Intent / User-visible effect / Verification /
152
+ Risk-rollback text block; pass "-" to read stdin
153
+ (mutually exclusive with --summary)
154
+ tot ${verb} --no-wait push and exit without polling for the reconcile result
155
+ tot ${verb} --no-open don't open the preview URL in the browser on success
156
+ tot ${verb} --json machine-readable result on stdout (candidate id, PR,
157
+ head SHA, preview URL, reconcile/compliance evidence)
158
+ — implies --no-open, no spinner
159
+ tot ${verb} --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
91
160
 
92
- By default a re-submit UPDATES your open candidate PR (like pushing more commits
161
+ By default a re-run UPDATES your open candidate PR (like pushing more commits
93
162
  to a GitHub PR), rather than opening a new one each time. Use --new to fork a
94
- fresh candidate PR; the next plain \`tot submit\` then updates THAT one. Manage your
163
+ fresh candidate PR; the next plain \`tot ${verb}\` then updates THAT one. Manage your
95
164
  open candidates with \`tot pr\` (list / view / close). If your candidate was already
96
- merged or closed, a re-submit automatically opens a fresh one.
165
+ merged or closed, a re-run automatically opens a fresh one.
166
+
167
+ Working on one thing at a time? You don't need --new, a git branch, or any
168
+ branch management at all — just keep editing and re-running \`tot ${verb}\`.
169
+
170
+ Once a preview reconciles cleanly, \`tot ship\` promotes it live.
97
171
 
98
172
  If you omit -m, a summary is generated from git (commit subject + the diff vs
99
173
  what's live in preview) so the change record the approver reviews is never blank.`;
174
+ }
100
175
 
101
176
  // Default bounded wait (~20s, matching the pre-E2 fixed poll's total budget) vs.
102
177
  // --watch's longer per-call long-poll + more attempts (~8 min ceiling) for a dev
@@ -130,14 +205,362 @@ export function buildChangeSummary({ message, summary, headSubject = "", statLin
130
205
 
131
206
  /** Print the change summary block — the SAME title/body carried into `candidate_open`
132
207
  * (below) as the PR title/description, so what the approver reads in the PR matches
133
- * what's printed here. */
134
- function printChangeSummary({ title, body, autoTitle }) {
208
+ * what's printed here. `quiet` (--json) suppresses the human-readable print — the
209
+ * same data reaches the caller via the JSON result instead (see buildJsonResult). */
210
+ function printChangeSummary({ title, body, autoTitle }, { quiet = false } = {}) {
211
+ if (quiet) return;
135
212
  console.log(`\n Change summary (for the approver / the change record):`);
136
213
  console.log(` ${title}`);
137
214
  for (const l of body) console.log(` ${l}`);
138
215
  if (autoTitle) console.log(` (auto-generated from git — pass -m "…" / --summary "…" to refine)`);
139
216
  }
140
217
 
218
+ // ─── structured candidate summary (--summary-file / stdin, P2 item 14) ──────────
219
+
220
+ /**
221
+ * The four fields the candidate body prefers (see
222
+ * docs/architecture/branch-lifecycle-and-integration-preview.md §"Change
223
+ * descriptions and AI assistance") — `key` is the JSON key, `label` the
224
+ * canonical text-block heading, `match` the label spellings
225
+ * parseLabeledSummary recognizes for that field (case-insensitive).
226
+ */
227
+ const STRUCTURED_FIELDS = [
228
+ { key: "intent", label: "Intent", match: /^intent$/i },
229
+ { key: "effect", label: "User-visible effect", match: /^(user-visible effect|effect)$/i },
230
+ { key: "verification", label: "Verification", match: /^verification$/i },
231
+ { key: "risk", label: "Risk / rollback", match: /^(risk\s*\/?\s*rollback|risk-rollback|risk)$/i },
232
+ ];
233
+
234
+ /**
235
+ * Parse the labeled four-field text block (`Intent: …` / `User-visible effect: …`
236
+ * / `Verification: …` / `Risk / rollback: …`) into `{intent, effect, verification,
237
+ * risk}` — a field's value continues across following lines until the next
238
+ * recognized label, so multi-line prose under one label is preserved. Unlabeled
239
+ * leading text (and anything before the first recognized label) is dropped —
240
+ * callers fall back to treating the whole file as freeform body when nothing
241
+ * matches at all. Pure — unit-tested.
242
+ * @param {string} text
243
+ * @returns {{intent?: string, effect?: string, verification?: string, risk?: string}}
244
+ */
245
+ export function parseLabeledSummary(text) {
246
+ const fields = {};
247
+ let current = null;
248
+ let buf = [];
249
+ const flush = () => {
250
+ if (current) fields[current] = buf.join("\n").trim();
251
+ buf = [];
252
+ };
253
+ for (const line of String(text).split(/\r?\n/)) {
254
+ const m = /^([A-Za-z][A-Za-z /-]*?)\s*:\s*(.*)$/.exec(line);
255
+ const field = m && STRUCTURED_FIELDS.find((f) => f.match.test(m[1].trim()));
256
+ if (field) {
257
+ flush();
258
+ current = field.key;
259
+ buf = m[2] ? [m[2]] : [];
260
+ } else if (current) {
261
+ buf.push(line);
262
+ }
263
+ }
264
+ flush();
265
+ return fields;
266
+ }
267
+
268
+ /**
269
+ * Parse `--summary-file` content as JSON — `{intent, effect, verification, risk}`
270
+ * (extra keys ignored, each value trimmed, blank/non-string values dropped).
271
+ * Returns null when the text isn't a JSON object at all, so the caller falls
272
+ * back to the labeled-text parser rather than treating a JSON parse error as
273
+ * "no fields". Pure — unit-tested.
274
+ * @param {string} text
275
+ * @returns {{intent?: string, effect?: string, verification?: string, risk?: string}|null}
276
+ */
277
+ export function parseJsonSummary(text) {
278
+ let obj;
279
+ try {
280
+ obj = JSON.parse(text);
281
+ } catch {
282
+ return null;
283
+ }
284
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
285
+ const fields = {};
286
+ for (const { key } of STRUCTURED_FIELDS) {
287
+ if (typeof obj[key] === "string" && obj[key].trim()) fields[key] = obj[key].trim();
288
+ }
289
+ return fields;
290
+ }
291
+
292
+ /**
293
+ * Render recognized structured fields back to the canonical labeled block — the
294
+ * SAME shape the contract prescribes — so a JSON or labeled-text `--summary-file`
295
+ * produces an identical PR/candidate body to a human writing
296
+ * `--summary "Intent: …"` by hand. Only fields actually present are emitted.
297
+ * Pure — unit-tested.
298
+ * @param {{intent?: string, effect?: string, verification?: string, risk?: string}} fields
299
+ * @returns {string[]}
300
+ */
301
+ export function formatStructuredSummary(fields) {
302
+ return STRUCTURED_FIELDS.filter(({ key }) => fields[key]).map(({ key, label }) => `${label}: ${fields[key]}`);
303
+ }
304
+
305
+ /**
306
+ * Resolve `--summary-file`/stdin content to `--summary`'s body text (so it
307
+ * flows through buildChangeSummary/candidate_open exactly like an inline
308
+ * `--summary`, per the "preserve -m/--summary unchanged" requirement — this is
309
+ * purely an alternate SOURCE for the same body string). JSON wins if the
310
+ * content parses as an object with at least one recognized field; else the
311
+ * labeled text block; else — no recognized structure at all — the raw content
312
+ * is used verbatim as freeform summary text, so automation isn't forced into
313
+ * the four-field shape. Pure — unit-tested.
314
+ * @param {string} text
315
+ * @returns {string}
316
+ */
317
+ export function summaryFromStructuredText(text) {
318
+ const trimmed = String(text ?? "").trim();
319
+ if (!trimmed) return "";
320
+ const json = parseJsonSummary(trimmed);
321
+ if (json && Object.keys(json).length) return formatStructuredSummary(json).join("\n");
322
+ const labeled = parseLabeledSummary(trimmed);
323
+ if (Object.keys(labeled).length) return formatStructuredSummary(labeled).join("\n");
324
+ return trimmed;
325
+ }
326
+
327
+ /** Read `--summary-file <path>` content — `"-"` reads stdin (fd 0), the same
328
+ * dash-means-stdin convention `tot app` uses (see app/dev.mjs readInput), so
329
+ * an LLM/automation caller can pipe the structured summary in without a temp
330
+ * file. Throws on a real read failure (missing file, permissions) — the
331
+ * caller reports it. */
332
+ export function readSummaryFileContent(pathOrDash) {
333
+ if (pathOrDash === "-") return readFileSync(0, "utf8");
334
+ return readFileSync(pathOrDash, "utf8");
335
+ }
336
+
337
+ // ─── stale-base freshness preflight (unit u16) ──────────────────────────────────
338
+
339
+ /** The candidate base branch every submit targets (mirrors tot-mcp's own
340
+ * `DEFAULT_CANDIDATE_BASE` and sync.mjs's `DEFAULT_SYNC_BRANCH` — the SAME
341
+ * protected branch by three different names in three different modules; kept
342
+ * a local constant here, not imported, since this file already resolves its
343
+ * own defaults independently of sync.mjs and candidate_open's server default). */
344
+ export const FRESHNESS_BASE_BRANCH = "preview";
345
+
346
+ /**
347
+ * Detect a STALE local view of the base branch before minting a candidate —
348
+ * the live-repeat incident this guards against: the checkout's `origin/preview`
349
+ * tracking ref was stale (recorded before a just-merged PR moved it), so a
350
+ * fresh `tot preview --new` built a candidate rooted in the OLD tip and got an
351
+ * instant, entirely avoidable "not mergeable" the moment it was compared
352
+ * against the real, already-advanced `preview`.
353
+ *
354
+ * Compares what the LOCAL checkout believes the base's tip is
355
+ * (`refs/remotes/origin/<branch>`, only as fresh as the last explicit fetch)
356
+ * against its ACTUAL current tip on the forge (`git ls-remote`, a lightweight
357
+ * single-ref read — no full fetch, no local ref mutated). Returns the live
358
+ * remote sha when local's cached view is behind it, or `null` when: the two
359
+ * already agree, there is no local tracking ref to compare against yet (a
360
+ * checkout that has simply never fetched this branch — never a false block on
361
+ * that), or the remote can't be reached right now (a network hiccup must
362
+ * never block a submit that would otherwise succeed; the push itself is the
363
+ * real connectivity test). Pure git I/O via the injected runner — unit-tested.
364
+ * @param {(cargs:string[])=>string} git
365
+ * @param {string} branch
366
+ * @returns {string|null}
367
+ */
368
+ export function detectStaleBase(git, branch) {
369
+ let localSha = "";
370
+ try {
371
+ localSha = git(["rev-parse", "-q", "--verify", `refs/remotes/origin/${branch}`]).trim();
372
+ } catch {
373
+ return null; // never fetched this branch locally — nothing cached to be stale
374
+ }
375
+ if (!localSha) return null;
376
+ let remoteSha = "";
377
+ try {
378
+ remoteSha = (git(["ls-remote", "origin", branch]).split(/\s+/)[0] || "").trim();
379
+ } catch {
380
+ return null; // can't reach the remote right now — don't block on a network hiccup
381
+ }
382
+ if (!remoteSha || remoteSha === localSha) return null;
383
+ return remoteSha;
384
+ }
385
+
386
+ // ─── auto-commit the known content trees (unit u2) ───────────────────────────────
387
+
388
+ /**
389
+ * The content trees `tot preview` may auto-commit on a dirty tree — and ONLY
390
+ * these. A store's reviewable content lives here; anything a developer edits
391
+ * OUTSIDE these (src/, config, stray files) is out of scope for an automatic
392
+ * commit and is REFUSED rather than silently swept in. Explicit paths only —
393
+ * never `git add -A`/`git add .`.
394
+ */
395
+ export const KNOWN_CONTENT_TREES = ["content/", "public/", ".tot/"];
396
+ export const KNOWN_CONTENT_FILES = ["theme.json"];
397
+
398
+ /** Is this repo-relative path inside a known content tree (or the one known
399
+ * top-level file)? Pure — unit-tested. */
400
+ export function isKnownContentPath(path) {
401
+ const p = String(path).replace(/^\.\//, "");
402
+ return KNOWN_CONTENT_FILES.includes(p) || KNOWN_CONTENT_TREES.some((t) => p.startsWith(t));
403
+ }
404
+
405
+ /**
406
+ * Flatten `git status --porcelain` (v1) output to the ordered, deduped set of
407
+ * dirty repo-relative paths. Each line is `XY <path>`; a rename/copy is
408
+ * `XY <old> -> <new>` and contributes BOTH paths (the old one is being removed
409
+ * from its tree too, so it's part of the scope decision). Run with
410
+ * `core.quotePath=false` upstream so unicode paths arrive literal. Pure —
411
+ * unit-tested.
412
+ * @param {string} text
413
+ * @returns {string[]}
414
+ */
415
+ export function parsePorcelainPaths(text) {
416
+ const seen = new Set();
417
+ const out = [];
418
+ for (const line of String(text).split("\n")) {
419
+ if (line.length < 4) continue; // "XY p" is the shortest real entry
420
+ const rest = line.slice(3);
421
+ const parts = rest.includes(" -> ") ? rest.split(" -> ") : [rest];
422
+ for (const raw of parts) {
423
+ const p = raw.trim();
424
+ if (p && !seen.has(p)) {
425
+ seen.add(p);
426
+ out.push(p);
427
+ }
428
+ }
429
+ }
430
+ return out;
431
+ }
432
+
433
+ /**
434
+ * Partition dirty paths into the known content trees vs everything else. Pure —
435
+ * unit-tested.
436
+ * @param {string[]} paths
437
+ * @returns {{ known: string[], unknown: string[] }}
438
+ */
439
+ export function classifyDirtyPaths(paths) {
440
+ const known = [];
441
+ const unknown = [];
442
+ for (const p of paths) (isKnownContentPath(p) ? known : unknown).push(p);
443
+ return { known, unknown };
444
+ }
445
+
446
+ /** A concise auto commit subject when the developer gave no -m — derived from the
447
+ * staged files so the commit is never a blank "(untitled change)". Pure. */
448
+ export function autoCommitSubject(files = []) {
449
+ if (files.length === 0) return "update store content";
450
+ if (files.length === 1) return `update ${files[0]}`;
451
+ return `update ${files.length} content files`;
452
+ }
453
+
454
+ /**
455
+ * The message for `tot preview`'s pre-preview auto-commit. An explicit `-m` wins
456
+ * as the subject; otherwise autoCommitSubject derives one from the staged files.
457
+ * The body (shortstat + file list) is built by the SAME buildChangeSummary the
458
+ * approver summary uses, so a commit and the change record it later feeds read
459
+ * consistently. Returns a git commit message (subject, blank line, body). Pure —
460
+ * unit-tested.
461
+ * @param {{ message?: string|null, files?: string[], statLine?: string }} input
462
+ * @returns {string}
463
+ */
464
+ export function buildAutoCommitMessage({ message, files = [], statLine = "" } = {}) {
465
+ const subject = (message && message.trim()) || autoCommitSubject(files);
466
+ const { body } = buildChangeSummary({ message: subject, files, statLine });
467
+ return body.length ? `${subject}\n\n${body.join("\n")}` : subject;
468
+ }
469
+
470
+ /**
471
+ * Detect an in-progress rebase, merge, or cherry-pick in the workspace (unit u8) —
472
+ * `tot preview`/`tot submit` must NEVER auto-commit over one of these. A rebase that
473
+ * stopped at "Could not apply" (or a merge/cherry-pick left with real conflicts) IS
474
+ * a dirty tree from `git status`'s point of view, so `autoCommitKnownTrees` would
475
+ * otherwise stage + commit the half-resolved content straight into a plain "content
476
+ * update" commit — silently finishing the git operation WRONG and losing whatever
477
+ * edit was still sitting in conflict markers or unresolved hunks (the live incident
478
+ * this guards against: a stalled rebase was never continued, `tot preview` ran
479
+ * anyway, and the developer's own edit was gone). Detection is worktree-safe:
480
+ * MERGE_HEAD/CHERRY_PICK_HEAD via plumbing refs (no direct `.git` path assumption),
481
+ * and rebase-merge/rebase-apply via `--git-path` (a linked worktree's git-dir lives
482
+ * OUTSIDE `<workspace>/.git`, so a literal `.git/rebase-merge` check would miss it).
483
+ * Returns which operation is in progress, or null when the tree is clean of one.
484
+ * @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
485
+ * @param {string} workspace absolute path `git` runs `-C` against — `--git-path`'s
486
+ * output may be relative, and Node's `existsSync` resolves relative paths against
487
+ * the CLI process's own cwd, not the checkout, so this pins the resolution base.
488
+ * @returns {"rebase"|"merge"|"cherry-pick"|null}
489
+ */
490
+ export function detectInProgressGitOperation(git, workspace) {
491
+ const hasRef = (name) => {
492
+ try {
493
+ return git(["rev-parse", "-q", "--verify", name]).trim().length > 0;
494
+ } catch {
495
+ return false;
496
+ }
497
+ };
498
+ if (hasRef("MERGE_HEAD")) return "merge";
499
+ if (hasRef("CHERRY_PICK_HEAD")) return "cherry-pick";
500
+ const hasGitPath = (name) => {
501
+ try {
502
+ const p = git(["rev-parse", "--git-path", name]).trim();
503
+ // An empty result should never happen for a real `--git-path` (it always
504
+ // echoes SOME path, existing or not) — but treat it as "absent" rather than
505
+ // resolving it, since `resolvePath(workspace, "")` degrades to `workspace`
506
+ // itself, which trivially always exists (a false "rebase in progress" on
507
+ // every call, not just an occasional false negative).
508
+ return p.length > 0 && existsSync(resolvePath(workspace, p));
509
+ } catch {
510
+ return false;
511
+ }
512
+ };
513
+ if (hasGitPath("rebase-merge") || hasGitPath("rebase-apply")) return "rebase";
514
+ return null;
515
+ }
516
+
517
+ /** The abort command that recovers from each in-progress git operation, so the
518
+ * refusal below can tell a developer exactly what to run. Pure. */
519
+ export function abortCommandFor(op) {
520
+ return op === "merge" ? "git merge --abort" : op === "cherry-pick" ? "git cherry-pick --abort" : "git rebase --abort";
521
+ }
522
+
523
+ /**
524
+ * Auto-commit the known content trees before previewing (unit u2). On a DIRTY
525
+ * tree `tot preview` commits your content edits for you, so a preview always
526
+ * reflects your working changes — but ONLY the known trees (content/, public/,
527
+ * theme.json, .tot/), staged by EXPLICIT path (never `git add -A`). If anything
528
+ * dirty falls OUTSIDE those, it REFUSES (out-of-scope src/config/stray edits are
529
+ * never silently swept into a store commit). --no-commit opts out entirely
530
+ * (preview whatever's already committed — u1's behavior).
531
+ *
532
+ * Returns exactly one of:
533
+ * { skipped: true } — --no-commit.
534
+ * { inProgress: "rebase"|… } — a rebase/merge/cherry-pick is unresolved;
535
+ * caller refuses rather than auto-committing
536
+ * over the developer's own half-resolved tree.
537
+ * { clean: true } — nothing dirty; preview HEAD as-is.
538
+ * { refused, unknown, known } — out-of-scope dirt; caller refuses + hints.
539
+ * { committed: true, sha, files } — staged the known dirty paths and committed.
540
+ *
541
+ * @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
542
+ * @param {{ message?: string|null, noCommit?: boolean, workspace?: string }} [opts]
543
+ */
544
+ export function autoCommitKnownTrees(git, { message = null, noCommit = false, workspace = "." } = {}) {
545
+ if (noCommit) return { skipped: true };
546
+ const inProgress = detectInProgressGitOperation(git, workspace);
547
+ if (inProgress) return { inProgress };
548
+ const status = git(["-c", "core.quotePath=false", "status", "--porcelain", "--untracked-files=all"]);
549
+ const dirty = parsePorcelainPaths(status);
550
+ if (dirty.length === 0) return { clean: true };
551
+ const { known, unknown } = classifyDirtyPaths(dirty);
552
+ if (unknown.length) return { refused: true, unknown, known };
553
+ // Stage ONLY the known dirty paths, each by explicit path — this records adds,
554
+ // modifications AND deletions within them, and can never reach outside the set.
555
+ git(["add", "--", ...known]);
556
+ const files = parseNameStatus(git(["diff", "--cached", "--name-status"])).map((e) => e.path);
557
+ const statLine = git(["diff", "--cached", "--shortstat"]).trim();
558
+ const msg = buildAutoCommitMessage({ message, files, statLine });
559
+ git(["commit", "--no-verify", "-m", msg]);
560
+ const sha = git(["rev-parse", "HEAD"]).trim();
561
+ return { committed: true, sha, files };
562
+ }
563
+
141
564
  // ─── PR-backed candidate (g1b candidate_open, unit c1) ──────────────────────────
142
565
 
143
566
  /**
@@ -185,7 +608,7 @@ export function buildFilePatch(entries, readBlob) {
185
608
  }
186
609
  const buf = readBlob(e.path);
187
610
  const asUtf8 = buf.toString("utf8");
188
- const isCleanUtf8 = !asUtf8.includes("") && Buffer.from(asUtf8, "utf8").equals(buf);
611
+ const isCleanUtf8 = !asUtf8.includes("\x00") && Buffer.from(asUtf8, "utf8").equals(buf);
189
612
  patch.push(
190
613
  isCleanUtf8
191
614
  ? { path: e.path, content: asUtf8 }
@@ -213,22 +636,138 @@ export function repoNameFromRemote(remoteUrl) {
213
636
  }
214
637
  }
215
638
 
639
+ // ─── fresh-forge-credential push (decision B — the invited-dev 401 dead-end) ─────
640
+
641
+ // splitAuthedRemote / basicAuthExtraHeader now live in ../git-credential.mjs (unit
642
+ // u10) — a dependency-free module BOTH this file and clone.mjs/commands/
643
+ // git-credential.mjs need, so they moved out of here to avoid a submit.mjs ↔
644
+ // clone.mjs import cycle. Re-exported so every existing import of these two names
645
+ // FROM submit.mjs (this file's own callers below, plus tests) keeps working
646
+ // unchanged.
647
+ export { splitAuthedRemote, basicAuthExtraHeader } from "../git-credential.mjs";
648
+ import { splitAuthedRemote, basicAuthExtraHeader, ensureTokenlessRemote } from "../git-credential.mjs";
649
+
650
+ /**
651
+ * Recognise a forge auth failure (expired / invalid push token) in a failed git
652
+ * push's stderr, so `tot preview` can re-mint a fresh credential and retry once
653
+ * rather than dead-ending on a stale token (the belt-and-suspenders half of
654
+ * decision B). Pure — unit-tested.
655
+ * @param {string} text
656
+ * @returns {boolean}
657
+ */
658
+ export function isForgeAuthError(text) {
659
+ return /\b40[13]\b|failed to authenticate|authentication failed|invalid credentials|access denied/i.test(
660
+ String(text || ""),
661
+ );
662
+ }
663
+
664
+ /**
665
+ * Derive the checkout's forge tag from its repo name (`"<tenant>-<tag>"`), so a
666
+ * re-mint targets the SAME repo the checkout points at. Defaults to "main" when the
667
+ * repo is bare (`"<tenant>"`, post-8425) or the `<tenant>-` prefix doesn't match, so
668
+ * the mint degrades to the clone default rather than a wrong tag. Pure —
669
+ * unit-tested.
670
+ * @param {string|null} repoName
671
+ * @param {string} tenant
672
+ * @returns {string}
673
+ */
674
+ export function tagFromRepoName(repoName, tenant) {
675
+ const r = String(repoName || "");
676
+ const prefix = `${tenant}-`;
677
+ return r.startsWith(prefix) && r.length > prefix.length ? r.slice(prefix.length) : "main";
678
+ }
679
+
216
680
  /**
217
- * A STABLE per-developer-per-tenant candidate handle so repeat `tot submit`
218
- * runs update the SAME PR instead of opening a new one each time
681
+ * Push the preview ref (decision B): mint a FRESH, short-lived forge credential
682
+ * right before the push and hand it to git EPHEMERALLY (via `http.extraheader` on
683
+ * a per-invocation `-c` — never written to `.git/config`), re-minting once on an
684
+ * auth failure. The push credential the MCP baked into `.git/config` at clone time
685
+ * expires within hours; reusing that stale embedded token is the invited-dev
686
+ * "`tot preview` → Gitea 401 dead-end". We push over the named `origin` remote with
687
+ * its URL overridden to the tokenless public URL for this one invocation, so the
688
+ * remote-tracking ref still updates while no long-lived secret lands on disk.
689
+ *
690
+ * When the mint is unavailable (older MCP, transient failure — `mintRemote` returns
691
+ * null) or the minted URL carries no parseable token, it falls back to pushing over
692
+ * the checkout's EXISTING remote (pre-B behavior) — no regression.
693
+ *
694
+ * @param {(cargs:string[])=>string} git throwing git runner (execFileSync-backed)
695
+ * @param {() => Promise<string|null>} mintRemote mints a fresh authed gitRemote (null when unavailable)
696
+ * @param {{ ref: string }} opts
697
+ * @returns {Promise<{ out: string }>} resolves on a successful push; throws (git's error) otherwise
698
+ */
699
+ export async function pushPreviewRef(git, mintRemote, { ref } = {}) {
700
+ const attempt = (remote) => {
701
+ const cred = splitAuthedRemote(remote);
702
+ if (!cred) {
703
+ // No fresh credential to hand over — push over the checkout's existing remote.
704
+ return git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
705
+ }
706
+ // Ephemeral auth: override the remote URL to the tokenless public URL and supply
707
+ // the credential as a one-shot Authorization header, with any OS credential
708
+ // helper disabled — none of this touches `.git/config`.
709
+ return git([
710
+ "-c", `remote.origin.url=${cred.publicUrl}`,
711
+ "-c", `http.extraheader=${basicAuthExtraHeader(cred.username, cred.token)}`,
712
+ "-c", "credential.helper=",
713
+ "push", "-f", "origin", `HEAD:refs/heads/${ref}`,
714
+ ]);
715
+ };
716
+
717
+ const remote = await mintRemote();
718
+ try {
719
+ return { out: attempt(remote) };
720
+ } catch (e) {
721
+ if (!isForgeAuthError(e?.stderr || e?.message || e)) throw e;
722
+ // Belt-and-suspenders: an auth failure re-mints a fresh credential and retries once.
723
+ return { out: attempt(await mintRemote()) };
724
+ }
725
+ }
726
+
727
+ /**
728
+ * A STABLE per-developer-per-tenant(-per-branch) candidate handle — so repeat
729
+ * `tot submit` runs update the SAME PR instead of opening a new one each time
219
730
  * (`candidate_open` is idempotent on `changeId`). No local state file needed:
220
- * it's a deterministic hash of the tenant + the acting identity, recomputed
221
- * fresh every run. Two different developers submitting to the same tenant get
222
- * two different (non-colliding) candidates. Pure — unit-tested.
731
+ * it's a deterministic hash of the tenant + the acting identity (+ the git branch
732
+ * on a non-default branch), recomputed fresh every run. Two different developers
733
+ * submitting to the same tenant get two different (non-colliding) candidates; so
734
+ * do the SAME developer on two different feature branches (u4 — branch-bound
735
+ * candidates), so `git checkout` acts as the PR switcher.
736
+ *
737
+ * ZERO MIGRATION: on the DEFAULT branch (main/master, or an unresolvable branch)
738
+ * the hash material is `tenant|actorKey` — byte-identical to the pre-u4 id — so an
739
+ * existing dev's current candidate keeps working untouched. A non-default branch
740
+ * folds the branch into the material (`tenant|actorKey|branch`) for its own id.
741
+ * Pure — unit-tested.
223
742
  * @param {string} tenant
224
743
  * @param {string} actorKey
744
+ * @param {string|null} [branch] current git branch; default/null ⇒ today's id
225
745
  * @returns {string}
226
746
  */
227
- export function deriveChangeId(tenant, actorKey) {
228
- const hash = createHash("sha256").update(`${tenant}|${actorKey}`).digest("hex").slice(0, 16);
747
+ export function deriveChangeId(tenant, actorKey, branch = null) {
748
+ const material = isDefaultBranch(branch) ? `${tenant}|${actorKey}` : `${tenant}|${actorKey}|${branch}`;
749
+ const hash = createHash("sha256").update(material).digest("hex").slice(0, 16);
229
750
  return `local-${hash}`;
230
751
  }
231
752
 
753
+ /**
754
+ * The current git branch in `workspace`, or null when it can't be resolved (a
755
+ * detached HEAD reports "HEAD", and any git failure is swallowed) — null is read
756
+ * by `isDefaultBranch` as the default branch, so an unresolvable branch keeps
757
+ * today's (branch-less) candidate rather than minting a spurious namespace. `git`
758
+ * is injected (a `(args:string[])=>string` runner) so it's testable. Best-effort.
759
+ * @param {(args:string[]) => string} git
760
+ * @returns {string|null}
761
+ */
762
+ export function currentBranch(git) {
763
+ try {
764
+ const b = git(["rev-parse", "--abbrev-ref", "HEAD"]).trim();
765
+ return b && b !== "HEAD" ? b : null;
766
+ } catch {
767
+ return null;
768
+ }
769
+ }
770
+
232
771
  /** The stable identity key behind `deriveChangeId` — the signed-in developer's
233
772
  * email, falling back to the token, then a generic label. Single-plane: the only
234
773
  * identity `tot` carries is the developer's own OAuth session. */
@@ -236,6 +775,97 @@ export function actorKeyFor(session) {
236
775
  return session?.email || session?.token || "developer";
237
776
  }
238
777
 
778
+ /**
779
+ * Which candidate this submit lands on (gh-pr-like) — decided UP FRONT, before any
780
+ * network call, because it also determines the isolated git ref we push to
781
+ * (resolvePushRef, below): a re-submit updates the SAME candidate/ref by default;
782
+ * `--new` forks a fresh one.
783
+ * --new → fork a FRESH candidate id;
784
+ * otherwise → the remembered active candidate (from a prior --new / terminal
785
+ * roll), else the STABLE per-dev-per-tenant(-per-branch) default.
786
+ * `persist` reports whether the choice diverges from the stable default, so the
787
+ * caller knows whether to remember it as the new active pointer. `mint` is
788
+ * injected (defaults to mintFreshChangeId) so this is pure/deterministic in tests.
789
+ * Pure — unit-tested.
790
+ * @param {{ tenant: string, actorKey: string, branch?: string|null, active?: string|null,
791
+ * isNew?: boolean, mint?: (baseId: string) => string }} opts
792
+ * @returns {{ changeId: string, stableId: string, persist: boolean }}
793
+ */
794
+ export function chooseChangeId({ tenant, actorKey, branch = null, active = null, isNew = false, mint = mintFreshChangeId }) {
795
+ const stableId = deriveChangeId(tenant, actorKey, branch);
796
+ const changeId = isNew ? mint(stableId) : (active || stableId);
797
+ const persist = isNew || (!!active && active !== stableId);
798
+ return { changeId, stableId, persist };
799
+ }
800
+
801
+ /**
802
+ * The forge state of ONE candidate (`"open"`/`"merged"`/`"closed"`/…), read by
803
+ * changeId via `candidate_status`, or null when it can't be POSITIVELY determined —
804
+ * the candidate isn't found, carries no state, or the read throws. null ("couldn't
805
+ * tell") is the deliberately SAFE answer: the caller (resolveActivePointer) then
806
+ * behaves exactly as if the pointer were still live, so a purely-diagnostic check
807
+ * that can't run never blocks, changes, or crashes a submit (u17 acceptance #3).
808
+ * Tolerates the tool returning a bare candidate, a `{candidates:[…]}` list, or a
809
+ * plain array. Injectable client for tests.
810
+ * @param {{callTool:Function}} client
811
+ * @param {{ repo: string, changeId: string }} opts
812
+ * @returns {Promise<string|null>}
813
+ */
814
+ export async function candidateStateFor(client, { repo, changeId }) {
815
+ try {
816
+ const r = await client.callTool("candidate_status", { repo, changeId });
817
+ const c = Array.isArray(r)
818
+ ? r.find((x) => x?.changeId === changeId)
819
+ : Array.isArray(r?.candidates)
820
+ ? r.candidates.find((x) => x?.changeId === changeId)
821
+ : r;
822
+ return c && typeof c.state === "string" ? c.state : null;
823
+ } catch {
824
+ return null;
825
+ }
826
+ }
827
+
828
+ /**
829
+ * u17 — before REUSING a remembered active-candidate pointer, confirm its PR is
830
+ * still open. The live incident this guards against: after a candidate PR merged, a
831
+ * plain `tot preview` reused the remembered pointer, force-pushed onto the now-DEAD
832
+ * candidate branch (stale old-base history), and `candidate_open` opened a NEW PR
833
+ * from it — inheriting a guaranteed conflict from the very first commit. When the
834
+ * pointer's PR has gone terminal (merged/closed) we DROP it here, so `chooseChangeId`
835
+ * falls back to the stable per-branch id exactly as if no pointer existed.
836
+ *
837
+ * PURELY DIAGNOSTIC — never blocks a submit over the check itself: no pointer, no
838
+ * repo, or a check that errors / can't positively confirm terminal all resolve to
839
+ * `{ active }` UNCHANGED (behave exactly as before). Only a POSITIVELY terminal
840
+ * state drops the pointer. When it does, `dropped` carries the old changeId + the
841
+ * terminal state so the caller can tell the operator and forget the on-disk pointer.
842
+ * Injectable client for tests.
843
+ * @param {{callTool:Function}} client
844
+ * @param {{ repo: string|null, active: string|null }} opts
845
+ * @returns {Promise<{ active: string|null, dropped?: { changeId: string, state: string } }>}
846
+ */
847
+ export async function resolveActivePointer(client, { repo, active }) {
848
+ if (!active || !repo) return { active };
849
+ const state = await candidateStateFor(client, { repo, changeId: active });
850
+ if (isTerminalCandidateState(state)) return { active: null, dropped: { changeId: active, state } };
851
+ return { active };
852
+ }
853
+
854
+ /**
855
+ * The ref `tot preview`/`tot submit` pushes to (b03 — stop force-pushing the
856
+ * SHARED `preview` ref). An explicit `--ref` always wins — the escape hatch /
857
+ * back-compat path (e.g. `--ref preview` reproduces the old shared-ref push for
858
+ * any tooling that still reads it by that literal name); otherwise it's YOUR OWN
859
+ * isolated candidate ref, so two developers — or the same developer on two
860
+ * branches — never force-push over each other or each other's preview. Pure —
861
+ * unit-tested.
862
+ * @param {{ ref?: string|null, changeId: string }} opts
863
+ * @returns {string}
864
+ */
865
+ export function resolvePushRef({ ref, changeId }) {
866
+ return ref || candidateRefFor(changeId);
867
+ }
868
+
239
869
  /**
240
870
  * Open/update the PR-backed candidate for this submit (g1b `candidate_open`,
241
871
  * unit c1 — the local-dev-loop half of the "PR-Backed Hosted Review Loop"
@@ -248,17 +878,19 @@ export function actorKeyFor(session) {
248
878
  * landed or the reconcile/compliance read-back that follows.
249
879
  * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
250
880
  * @param {{ repo: string|null, changeId: string, changeSummary: {title:string, body:string[]},
251
- * patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer }} opts
881
+ * patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer,
882
+ * quiet?: boolean }} opts `quiet` (--json) suppresses the human print; the same
883
+ * result is still returned for the caller's JSON payload.
252
884
  */
253
- export async function submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob }) {
885
+ export async function submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet = false }) {
254
886
  if (!repo) {
255
- console.log(` ~ couldn't derive the forge repo from the checkout's remote — skipping the PR-backed candidate.`);
887
+ if (!quiet) console.log(` ~ couldn't derive the forge repo from the checkout's remote — skipping the PR-backed candidate.`);
256
888
  return null;
257
889
  }
258
890
  try {
259
891
  const patch = buildFilePatch(patchEntries, readBlob);
260
892
  if (patch.length === 0) {
261
- console.log(` ~ no file changes to open a PR-backed candidate for.`);
893
+ if (!quiet) console.log(` ~ no file changes to open a PR-backed candidate for.`);
262
894
  return null;
263
895
  }
264
896
  const result = await client.callTool("candidate_open", {
@@ -271,57 +903,244 @@ export async function submitCandidate(client, { repo, changeId, changeSummary, p
271
903
  body: changeSummary.body.length ? changeSummary.body.join("\n").slice(0, 4000) : undefined,
272
904
  patch,
273
905
  });
274
- reportCandidate(result, changeId);
906
+ reportCandidate(result, changeId, { quiet });
275
907
  return result;
276
908
  } catch (e) {
277
- console.log(` ~ couldn't open/update the PR-backed candidate: ${String(e?.message || e)}`);
278
- console.log(` (best-effort your push is still in; this doesn't block reconcile.)`);
909
+ if (!quiet) {
910
+ console.log(` ~ couldn't open/update the PR-backed candidate: ${String(e?.message || e)}`);
911
+ console.log(` (best-effort — your push is still in; this doesn't block reconcile.)`);
912
+ }
279
913
  return null;
280
914
  }
281
915
  }
282
916
 
283
917
  /** Print the candidate_open result: the PR the approver reviews, or the MCP's own
284
- * refusal message when it couldn't open/update one. */
285
- function reportCandidate(result, changeId) {
918
+ * refusal message when it couldn't open/update one. `quiet` (--json) suppresses it.
919
+ * Deliberately does NOT print `result.url` — that's the INTERNAL forge (Gitea) PR
920
+ * link, plumbing a developer never needs to see (DZ, 2026-08-15); the product
921
+ * surface is the shareable /preview/<tenant>/pr/<N> URL printed right after
922
+ * (shareablePrUrl). The forge URL still rides the --json payload for tooling. */
923
+ function reportCandidate(result, changeId, { quiet = false } = {}) {
924
+ if (quiet) return;
286
925
  if (result && typeof result.prNumber === "number") {
287
926
  console.log(`\n ✓ candidate ${result.changeId || changeId} — PR #${result.prNumber} (${result.state || "open"})`);
288
- if (result.url) console.log(` ${result.url}`);
289
927
  return;
290
928
  }
291
929
  const msg = result?.message || (result?.raw && String(result.raw)) || JSON.stringify(result ?? null);
292
930
  console.log(` ~ PR-backed candidate not opened: ${msg}`);
293
931
  }
294
932
 
295
- /** @param {string[]} argv @param {any} ctx */
296
- export async function run(argv, ctx) {
933
+ /**
934
+ * Build the `--json` result object (P2 item 14): candidate id, PR, head SHA,
935
+ * shareable preview URL, and reconcile/compliance evidence — so an
936
+ * LLM/automation caller can consume structured data instead of scraping
937
+ * human-readable stdout. `ok` mirrors the process exit code (0 ⇒ true) so a
938
+ * caller can branch on one field.
939
+ *
940
+ * Also carries the honest-dispatch triad (the "never dispatched" fix) so
941
+ * automation gets the SAME truth the human-readable path does, never a
942
+ * prettier lie: `dispatched` (was a webhook delivery ever observed for this
943
+ * commit?), `notDispatched` (the permanent-dead-end tag from
944
+ * pollPreviewStatus — re-running will not help), and `delivery` (the raw
945
+ * observability triad, or null when nothing was ever seen). All three
946
+ * default to their "nothing known yet" value when `status` is absent/older,
947
+ * so a caller can branch on `notDispatched` unconditionally without a
948
+ * presence check.
949
+ *
950
+ * `previewPrUrl` (P2 item — the immediate Vercel-style shareable link, see
951
+ * shareablePrUrl) is threaded through separately from `previewUrl`: the
952
+ * latter is the server-minted, reconcile-confirmed link (null until reconcile
953
+ * actually lands); the former is composed client-side the instant the
954
+ * candidate PR opens and may point at a preview that's still building.
955
+ * Defaults to null when no numeric PR number was known at result-build time.
956
+ * Pure — unit-tested.
957
+ * @param {{ ok: boolean, ref?: string|null, commit?: string|null, changeId?: string|null,
958
+ * candidate?: {prNumber?: number, number?: number, state?: string, url?: string}|null,
959
+ * status?: {status?: string, reconcile?: object|null, compliance?: object|null,
960
+ * previewUrl?: string|null, shipped?: object|null, dispatched?: boolean|null,
961
+ * notDispatched?: boolean, delivery?: object|null}|null,
962
+ * previewPrUrl?: string|null, error?: string|null, note?: string|null }} input
963
+ */
964
+ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null, candidate = null, status = null, previewPrUrl = null, error = null, note = null }) {
965
+ return {
966
+ ok,
967
+ ref,
968
+ commit,
969
+ changeId,
970
+ candidate: candidate
971
+ ? { number: candidate.prNumber ?? candidate.number ?? null, state: candidate.state ?? null, url: candidate.url ?? null }
972
+ : null,
973
+ status: status?.status ?? null,
974
+ reconcile: status?.reconcile ?? null,
975
+ compliance: status?.compliance ?? null,
976
+ previewUrl: status?.previewUrl ?? null,
977
+ shipped: status?.shipped ?? null,
978
+ dispatched: status?.dispatched ?? null,
979
+ notDispatched: status?.notDispatched ?? false,
980
+ forwardFailed: status?.forwardFailed ?? false,
981
+ delivery: status?.delivery ?? null,
982
+ previewPrUrl,
983
+ ...(error ? { error } : {}),
984
+ ...(note ? { note } : {}),
985
+ };
986
+ }
987
+
988
+ /** Print the `--json` result as one pretty-printed object on stdout — a no-op
989
+ * unless `args.json` was passed, so call sites can invoke it unconditionally. */
990
+ function emitJson(args, payload) {
991
+ if (args.json) console.log(JSON.stringify(payload, null, 2));
992
+ }
993
+
994
+ /**
995
+ * The preview flow — validate, push the preview ref, open/update the PR-backed
996
+ * candidate, and stream back the reconcile/compliance/preview result. Reached by
997
+ * `tot preview` and, as teaching aliases, `tot submit` / `tot deploy` (preview.mjs
998
+ * wraps this and adds the verb-teaching hints). `verb` only brands the user-facing
999
+ * copy (usage + the not-in-checkout error) with whatever the developer typed.
1000
+ * `--json` (args.json) suppresses the human-readable stdout narration in favor of
1001
+ * one structured result object at the end (see buildJsonResult) — stderr
1002
+ * diagnostics (fail(), `~ …` progress lines) still print either way.
1003
+ * @param {string[]} argv @param {any} ctx @param {{ verb?: string }} [opts]
1004
+ */
1005
+ export async function run(argv, ctx, { verb = "preview" } = {}) {
297
1006
  const env = process.env;
298
1007
  const args = parseArgs(argv);
299
1008
  if (args.help) {
300
- console.log(USAGE);
1009
+ console.log(renderUsage(verb));
301
1010
  return 0;
302
1011
  }
1012
+ if (args.summary && args.summaryFile) {
1013
+ const msg = "--summary and --summary-file are mutually exclusive";
1014
+ console.error(fail(msg, "pass one or the other"));
1015
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1016
+ return 2;
1017
+ }
1018
+ if (args.summaryFile) {
1019
+ let raw;
1020
+ try {
1021
+ raw = readSummaryFileContent(args.summaryFile);
1022
+ } catch (e) {
1023
+ const msg = `couldn't read --summary-file ${args.summaryFile}: ${String(e?.message || e)}`;
1024
+ console.error(fail(msg, `check the path (or pass "-" to read stdin)`));
1025
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1026
+ return 2;
1027
+ }
1028
+ // Feeds --summary's body unchanged from here on (buildChangeSummary etc.) —
1029
+ // --summary-file is purely an alternate SOURCE for the same string, per the
1030
+ // "preserve -m/--summary unchanged" requirement.
1031
+ args.summary = summaryFromStructuredText(raw);
1032
+ }
303
1033
  if (ctx.mode !== "checkout") {
304
- console.error(
305
- fail(
306
- "`tot submit` runs from inside a tenant checkout",
307
- "tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)",
308
- ),
309
- );
1034
+ const msg = `\`tot ${verb}\` runs from inside a tenant checkout`;
1035
+ console.error(fail(msg, "tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)"));
1036
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
310
1037
  return 2;
311
1038
  }
312
1039
  const workspace = ctx.workspacePath;
313
1040
  const tenant = ctx.tenant;
314
1041
  const git = (cargs) => execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
315
1042
 
1043
+ // Self-heal a LEGACY checkout (unit u10): strip any token still embedded in
1044
+ // `origin`'s URL and install the credential helper, so this run (and every one
1045
+ // after) mints fresh creds through `tot` instead of relying on one that quietly
1046
+ // expired. Best-effort — never blocks the actual preview on a migration hiccup.
1047
+ try {
1048
+ ensureTokenlessRemote(git);
1049
+ } catch {
1050
+ /* best-effort — see above */
1051
+ }
1052
+
1053
+ // Freshness preflight (unit u16) — BEFORE minting anything: is the checkout's
1054
+ // cached view of the base branch already behind the store? A candidate built
1055
+ // on a stale base is an instant, avoidable "not mergeable" the moment the
1056
+ // forge compares it against the real (already-advanced) base. --skip-freshness
1057
+ // opts out (e.g. offline/CI, or a deliberate re-run against a known-good tip).
1058
+ if (!args.skipFreshness) {
1059
+ let staleTip = null;
1060
+ try {
1061
+ staleTip = detectStaleBase(git, FRESHNESS_BASE_BRANCH);
1062
+ } catch {
1063
+ staleTip = null; // never block a submit on the preflight's OWN failure
1064
+ }
1065
+ if (staleTip) {
1066
+ const msg = `your checkout is behind the store — "${FRESHNESS_BASE_BRANCH}" has moved since your last sync`;
1067
+ console.error(
1068
+ 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`),
1069
+ );
1070
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1071
+ return 1;
1072
+ }
1073
+ }
1074
+
1075
+
1076
+ // 0. auto-commit the known content trees (unit u2) — on a dirty tree, commit your
1077
+ // content edits BEFORE previewing so the preview reflects your working changes.
1078
+ // Only content/, public/, theme.json, .tot/ (staged by explicit path, never
1079
+ // `git add -A`); anything dirty outside those is refused, not silently swept in.
1080
+ // --no-commit opts out (preview whatever's already committed).
1081
+ let auto;
1082
+ try {
1083
+ auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit, workspace });
1084
+ } catch (e) {
1085
+ emitGitOp("commit", false, { command: verb, errorClass: "git_commit_failed" });
1086
+ const msg = `auto-commit failed: ${String(e?.stderr || e?.message || e)}`;
1087
+ console.error(fail(msg, "commit your content manually (git add / git commit), or re-run with --no-commit"));
1088
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1089
+ return 1;
1090
+ }
1091
+ if (auto.committed) emitGitOp("commit", true, { command: verb });
1092
+ if (auto.inProgress) {
1093
+ const abortCmd = abortCommandFor(auto.inProgress);
1094
+ const msg = `a ${auto.inProgress} is still in progress here`;
1095
+ console.error(
1096
+ fail(
1097
+ msg,
1098
+ `finish it (resolve + continue) or back out (\`${abortCmd}\`), then re-run \`tot ${verb}\` — `
1099
+ + "auto-committing over an unresolved rebase/merge/cherry-pick would fold your half-resolved "
1100
+ + "tree into a plain content commit and can lose whatever edit was still unresolved",
1101
+ ),
1102
+ );
1103
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1104
+ return 1;
1105
+ }
1106
+ if (auto.refused) {
1107
+ console.error(
1108
+ fail(
1109
+ `${auto.unknown.length} change(s) are outside the store content trees — refusing to auto-commit`,
1110
+ "commit (or stash/revert) those yourself, then re-run — or use --no-commit to preview only what's already committed",
1111
+ ) + "\n",
1112
+ );
1113
+ for (const p of auto.unknown) console.error(` ✗ out of scope: ${p}`);
1114
+ console.error(`\n \`tot ${verb}\` auto-commits only: ${[...KNOWN_CONTENT_TREES, ...KNOWN_CONTENT_FILES].join(", ")}`);
1115
+ if (auto.known.length) console.error(` (in scope, would have been committed: ${auto.known.join(", ")})`);
1116
+ emitJson(args, buildJsonResult({ ok: false, error: `${auto.unknown.length} change(s) outside the store content trees` }));
1117
+ return 1;
1118
+ }
1119
+ if (auto.committed) {
1120
+ console.error(
1121
+ `~ auto-committed ${auto.sha.slice(0, 9)} (${auto.files.length} content file(s)) — pass -m "…" to set the message, --no-commit to skip`,
1122
+ );
1123
+ }
1124
+
316
1125
  // 1. validate locally — refuse on errors.
317
1126
  if (!args.skipValidate) {
318
1127
  const { ok, findings } = validateTenant(workspace, { tenantId: tenant, scope: ctx.config?.scope });
1128
+ // Advisory but LOUD: git conflict markers must never slip past as "validated"
1129
+ // (the half-resolved-rebase incident). Surfaced on the ok path too — warnings
1130
+ // are otherwise swallowed here — but they never block the submit.
1131
+ const conflicts = findings.filter((f) => f.rule === "git-conflict-markers");
1132
+ if (conflicts.length) {
1133
+ console.error(`\n⚠ git conflict markers in submitted content (${conflicts.length} file(s)) — an unfinished merge/rebase?`);
1134
+ for (const f of conflicts) console.error(` ⚠ ${f.file} — ${f.message}`);
1135
+ console.error(" The preview will still build, but it will serve the broken markers. Resolve before shipping.\n");
1136
+ }
319
1137
  if (!ok) {
320
1138
  const errs = findings.filter((f) => f.level === ERROR);
321
1139
  console.error(
322
1140
  fail(`${errs.length} validation error(s)`, "fix these (below), or re-run with --skip-validate") + "\n",
323
1141
  );
324
1142
  for (const f of errs) console.error(` ✗ [${f.rule}] ${f.file} — ${f.message}`);
1143
+ emitJson(args, buildJsonResult({ ok: false, error: `${errs.length} validation error(s)` }));
325
1144
  return 1;
326
1145
  }
327
1146
  console.error("~ validated (no errors)");
@@ -332,7 +1151,9 @@ export async function run(argv, ctx) {
332
1151
  try {
333
1152
  commit = git(["rev-parse", "HEAD"]).trim();
334
1153
  } catch {
335
- console.error(fail("no commits here yet", "git add <files> && git commit -m '…', then re-run"));
1154
+ const msg = "no commits here yet";
1155
+ console.error(fail(msg, "git add <files> && git commit -m '…', then re-run"));
1156
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
336
1157
  return 1;
337
1158
  }
338
1159
  const short = commit.slice(0, 9);
@@ -344,6 +1165,9 @@ export async function run(argv, ctx) {
344
1165
  // same diff (name-status, so candidate_open also knows adds/deletes/renames)
345
1166
  // doubles as the source of the PR-backed candidate's file patch (step 2b, below)
346
1167
  // — one git read, two consumers, so the PR always matches what's printed here.
1168
+ // Parameterized on `ref` (b03 — isolated candidate refs): the diff base is YOUR
1169
+ // candidate ref's own tracking ref, not a shared one, so the summary always reads
1170
+ // "vs your own last push" once the push ref is known (computed just below).
347
1171
  const gitSafe = (cargs) => {
348
1172
  try {
349
1173
  return git(cargs);
@@ -351,83 +1175,204 @@ export async function run(argv, ctx) {
351
1175
  return "";
352
1176
  }
353
1177
  };
354
- const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
355
- const trackingRef = `refs/remotes/origin/${args.ref}`;
356
- const base = gitSafe(["rev-parse", "--verify", "--quiet", trackingRef]).trim()
357
- ? trackingRef
358
- : gitSafe(["rev-parse", "--verify", "--quiet", "HEAD~1"]).trim()
359
- ? "HEAD~1"
360
- : "";
361
- const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
362
- const patchEntries = parseNameStatus(gitSafe(statusCmd));
363
- const files = patchEntries.map((e) => e.path);
364
- const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
365
- const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
366
-
367
- console.error(`~ pushing ${short} ${args.ref} (origin)`);
1178
+ function buildSummaryAndPatch(ref) {
1179
+ const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
1180
+ const trackingRef = `refs/remotes/origin/${ref}`;
1181
+ const base = gitSafe(["rev-parse", "--verify", "--quiet", trackingRef]).trim()
1182
+ ? trackingRef
1183
+ : gitSafe(["rev-parse", "--verify", "--quiet", "HEAD~1"]).trim()
1184
+ ? "HEAD~1"
1185
+ : "";
1186
+ const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
1187
+ const patchEntries = parseNameStatus(gitSafe(statusCmd));
1188
+ const files = patchEntries.map((e) => e.path);
1189
+ const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
1190
+ const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
1191
+ return { changeSummary, patchEntries };
1192
+ }
1193
+
1194
+ // The MCP session is needed BOTH to mint a fresh forge push credential (decision
1195
+ // B — right below) and for the candidate/preview read-back after, so establish it
1196
+ // ONCE, up front, and reuse it for the whole flow.
1197
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
1198
+ const client = createMcpClient(baseUrl);
1199
+ const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
1200
+
1201
+ // Branch-bound (u4) + the isolated candidate ref (b03): resolved BEFORE the push,
1202
+ // since the push target itself depends on it — so the raw git push and the
1203
+ // PR-backed candidate (step 2b, below) always land on the SAME branch. `active`/
1204
+ // `statePath` are local filesystem reads (candidate-state.mjs) — no session
1205
+ // needed — so they're available even on the no-session fallback path below.
1206
+ const branch = currentBranch(gitSafe);
1207
+ const statePath = defaultCandidateStatePath(env);
1208
+ let active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
1209
+
1210
+ let session;
368
1211
  try {
369
- const out = git(["push", "-f", "origin", `HEAD:refs/heads/${args.ref}`]);
370
- if (out.trim()) console.error(redactUrl(out.trim()));
1212
+ session = await establishSession(client, { env, prefer: args.identity || undefined });
371
1213
  } catch (e) {
372
- console.error(
373
- fail(
374
- `push failed: ${redactUrl(String(e.stderr || e.message || e))}`,
375
- "check your commit and that the checkout's remote is reachable, then re-run",
376
- ),
377
- );
1214
+ // Not signed in / MCP unreachable — we can't mint a fresh credential, so fall
1215
+ // back to pushing over the checkout's EXISTING embedded remote (pre-B behavior:
1216
+ // no worse than before) and skip the read-back that needs a session. The push
1217
+ // still lands if that embedded token is live. actorKeyFor(null) degrades to the
1218
+ // generic "developer" key — still isolated PER BRANCH (never the shared ref),
1219
+ // just not per-developer until sign-in succeeds.
1220
+ const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, isNew: args.new });
1221
+ const ref = resolvePushRef({ ref: args.ref, changeId });
1222
+ const { changeSummary } = buildSummaryAndPatch(ref);
1223
+ console.error(`~ pushing ${short} → ${ref} (origin)`);
1224
+ try {
1225
+ const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
1226
+ if (out.trim()) console.error(redactUrl(out.trim()));
1227
+ emitGitOp("push", true, { command: verb });
1228
+ } catch (pushErr) {
1229
+ emitGitOp("push", false, {
1230
+ command: verb,
1231
+ errorClass: isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr) ? "forge_auth" : "git_push_failed",
1232
+ });
1233
+ const msg = `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`;
1234
+ // This is the NO-SESSION path pushing the clone-time embedded credential —
1235
+ // which rotation kills the moment any fresh mint happens elsewhere. An auth
1236
+ // failure here is therefore almost always "you're not signed in IN THIS
1237
+ // SHELL", not a network problem; the old remote-is-reachable hint sent a
1238
+ // human down the wrong path live (Trello-13075 polish).
1239
+ const hint = isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr)
1240
+ ? "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"
1241
+ : "check your commit and that the checkout's remote is reachable, then re-run";
1242
+ console.error(fail(msg, hint));
1243
+ emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
1244
+ return 1;
1245
+ }
1246
+ if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
1247
+ printChangeSummary(changeSummary, { quiet: args.json });
1248
+ const note = e instanceof AuthUnavailableError
1249
+ ? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
1250
+ : `couldn't reach Token of Trust for the result read-back: ${describeReadbackError(e)}`;
1251
+ if (!args.json) {
1252
+ console.log(` (${note})`);
1253
+ console.log(` Your push is in; the preview updates once reconcile completes.`);
1254
+ }
1255
+ emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
1256
+ return 0;
1257
+ }
1258
+
1259
+ // u17 — before REUSING a remembered active pointer, confirm its PR is still open.
1260
+ // If it merged/closed we DROP it (and forget it on disk) so chooseChangeId falls
1261
+ // back to the stable id, rather than force-pushing onto a now-dead candidate branch
1262
+ // and opening a NEW PR that inherits a guaranteed conflict (the live incident this
1263
+ // guards against). Skipped under --new (chooseChangeId ignores `active` there
1264
+ // anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
1265
+ // Needs the tenant scope bound for candidate_status to resolve — idempotent with
1266
+ // the later client_switch / the fresh-mint checkoutTenant.
1267
+ if (active && repo && !args.new) {
1268
+ try {
1269
+ await client.callTool("client_switch", { tenant });
1270
+ } catch { /* scope bind is best-effort; candidateStateFor tolerates a miss */ }
1271
+ const resolved = await resolveActivePointer(client, { repo, active });
1272
+ if (resolved.dropped) {
1273
+ console.error(
1274
+ `~ remembered candidate ${resolved.dropped.changeId} is ${resolved.dropped.state} — dropping it and submitting fresh (a ${resolved.dropped.state} PR can't be reused).`,
1275
+ );
1276
+ try {
1277
+ clearActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch });
1278
+ } catch { /* best-effort local cleanup — a miss just re-checks next run */ }
1279
+ }
1280
+ active = resolved.active;
1281
+ }
1282
+
1283
+ // Which candidate (and therefore which isolated ref, b03) this submit targets —
1284
+ // decided now, with a real session, so the SAME id backs both the raw git push
1285
+ // (right below) and the PR-backed candidate (step 2b): the two never point at
1286
+ // different branches. See chooseChangeId's doc for the --new / active-pointer
1287
+ // rules.
1288
+ let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, isNew: args.new });
1289
+ const ref = resolvePushRef({ ref: args.ref, changeId });
1290
+ const { changeSummary, patchEntries } = buildSummaryAndPatch(ref);
1291
+
1292
+ // 2. push the preview ref with a FRESHLY-MINTED, short-lived forge credential
1293
+ // (decision B). The token baked into `.git/config` at clone time expires within
1294
+ // hours, so we re-mint right before the push and hand it to git ephemerally
1295
+ // (never persisted to `.git/config`), re-minting once on an auth failure.
1296
+ // `checkoutTenant(cloneDir:null)` mints without re-cloning AND client_switch()es,
1297
+ // binding the tenant scope the candidate/preview read-back below reads.
1298
+ const tag = tagFromRepoName(repo, tenant);
1299
+ const mintRemote = async () => {
1300
+ try {
1301
+ const res = await checkoutTenant(client, { tenant, tag, cloneDir: null, redact: redactUrl });
1302
+ return res.gitRemote || null;
1303
+ } catch (e) {
1304
+ console.error(
1305
+ `~ couldn't mint a fresh push credential (${redactUrl(String(e?.message || e))}) — using the checkout's remote`,
1306
+ );
1307
+ return null;
1308
+ }
1309
+ };
1310
+
1311
+ console.error(`~ pushing ${short} → ${ref} (origin, fresh credential)`);
1312
+ try {
1313
+ const { out } = await pushPreviewRef(git, mintRemote, { ref });
1314
+ if (out && out.trim()) console.error(redactUrl(out.trim()));
1315
+ emitGitOp("push", true, { command: verb });
1316
+ } catch (e) {
1317
+ emitGitOp("push", false, {
1318
+ command: verb,
1319
+ errorClass: isForgeAuthError(e?.stderr || e?.message || e) ? "forge_auth" : "git_push_failed",
1320
+ });
1321
+ const msg = `push failed: ${redactUrl(String(e.stderr || e.message || e))}`;
1322
+ console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
1323
+ emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
378
1324
  return 1;
379
1325
  }
380
- console.log(`\n+ submitted ${short} to ${args.ref}.`);
381
- printChangeSummary(changeSummary);
1326
+ if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
1327
+ printChangeSummary(changeSummary, { quiet: args.json });
382
1328
 
383
1329
  // 2b + 3. open/update the PR-backed candidate, then report reconcile +
384
- // compliance + preview URL from the MCP (both graceful seams, same session).
385
- const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
386
- const client = createMcpClient(baseUrl);
1330
+ // compliance + preview URL from the MCP reusing the session established above.
387
1331
  let progress = null;
388
1332
  try {
389
- // Attach auth before the first server call (developer bearer pre-initialize,
390
- // operator credential_validate post-initialize) see establishSession.
391
- const session = await establishSession(client, { env, prefer: args.identity || undefined });
392
- // Set the active tenant so preview_status/candidate_open read the right scope
393
- // (both key on the session's bound tenant/app — no tenant arg of their own).
1333
+ // Bind the active tenant so preview_status/candidate_open read the right scope
1334
+ // (idempotent checkoutTenant already switched when the fresh mint succeeded).
394
1335
  await client.callTool("client_switch", { tenant });
395
1336
 
396
1337
  // 2b. PR-backed candidate (g1b candidate_open, unit c1) — best-effort: a
397
1338
  // failure here (older MCP, VC not configured, preview-access capability) is
398
1339
  // reported and swallowed, never blocking the preview push that already landed.
399
- //
400
- // Which candidate this submit lands on (gh-pr-like):
401
- // --new → fork a FRESH candidate and remember it as active;
402
- // otherwise → the remembered active candidate (from a prior --new /
403
- // roll), else the STABLE per-dev-per-tenant default.
404
- // If the chosen candidate turns out to be merged/closed, roll to a fresh one
405
- // so a re-submit is never wedged on a dead PR.
406
- const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
1340
+ // `changeId`/`stableId`/`persist` were already decided above (they picked the
1341
+ // push ref too); if the chosen candidate turns out to be merged/closed, roll to
1342
+ // a fresh one so a re-submit is never wedged on a dead PR. (`repo` was derived
1343
+ // above.)
407
1344
  const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
408
- const statePath = defaultCandidateStatePath(env);
409
- const stableId = deriveChangeId(tenant, actorKeyFor(session));
410
- const active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo }) : null;
411
- let changeId = args.new ? mintFreshChangeId(stableId) : (active || stableId);
412
- // Persist when we diverge from the stable default (a --new fork, or a
413
- // previously-remembered active pointer) so the next plain submit follows it.
414
- let persist = args.new || (!!active && active !== stableId);
415
1345
 
416
- let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
1346
+ let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
417
1347
 
418
1348
  if (candidate && isTerminalCandidateState(candidate.state)) {
419
1349
  const rolled = mintFreshChangeId(stableId);
420
- console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
1350
+ if (!args.json) console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
421
1351
  changeId = rolled;
422
1352
  persist = true;
423
- candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
1353
+ candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
1354
+ }
1355
+
1356
+ // Immediate shareable URL (Vercel-style: "the URL exists before the build
1357
+ // does"). A non-terminal candidate with a real PR number means a preview
1358
+ // WILL be built at a deterministic route — so hand the developer that link
1359
+ // right now, before reconcile even starts, rather than making them wait for
1360
+ // the server-minted `previewUrl` (formatShareableUrlBlock) that only shows
1361
+ // up once reconcile actually completes. Honest framing: it's printed as
1362
+ // "building", never as "ready".
1363
+ const previewPrUrl =
1364
+ candidate && !isTerminalCandidateState(candidate.state) && typeof candidate.prNumber === "number"
1365
+ ? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, candidate.prNumber)
1366
+ : null;
1367
+ if (previewPrUrl && !args.json) {
1368
+ console.log(`\n ▸ Your preview will appear at:\n ${previewPrUrl}\n (building — this link goes live once reconcile completes)`);
424
1369
  }
425
1370
 
426
1371
  // Remember the active candidate only on a real, non-terminal open (best-effort;
427
1372
  // never let a state-write failure break the submit).
428
1373
  if (persist && repo && candidate && !isTerminalCandidateState(candidate.state)) {
429
1374
  try {
430
- writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, changeId });
1375
+ writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
431
1376
  } catch { /* best-effort local hint — a miss just re-derives the stable id */ }
432
1377
  }
433
1378
 
@@ -440,35 +1385,51 @@ export async function run(argv, ctx) {
440
1385
  // as live, not as a growing wall of "(1)…(8)" lines. The counter keeps
441
1386
  // ticking on its own 90ms timer even while a single poll long-polls for
442
1387
  // waitMs, so elapsed time is real wall-clock, not the attempt count.
1388
+ // --json is for automation: no interactive spinner (stays silent — the
1389
+ // JSON result carries the same status at the end).
1390
+ // Honest opening label (the incident this fixes: a job may never actually
1391
+ // get dispatched — see pollPreviewStatus's notDispatched short-circuit — so
1392
+ // the INITIAL text must not assert a reconcile job exists before one has
1393
+ // been observed). Once a tick confirms `s.dispatched === true` the 45s
1394
+ // stage text ("still reconciling…") IS truthful and is left as-is below.
443
1395
  let phase = "reconcile";
444
- progress = startProgress(`reconcile running for ${short}…`, {
445
- stages: [{ afterMs: 45_000, text: `still reconciling ${short} (larger changes take longer)` }],
446
- });
1396
+ if (!args.json) {
1397
+ progress = startProgress(`waiting for reconcile of ${short}…`, {
1398
+ stages: [{ afterMs: 45_000, text: `still reconciling ${short}… (larger changes take longer)` }],
1399
+ });
1400
+ }
447
1401
  status = await pollPreviewStatus(client, commit, {
448
1402
  ...(args.watch ? WATCH_POLL : DEFAULT_POLL),
449
1403
  onTick: (s) => {
450
1404
  // Reconcile is done but we're still waiting on a ship decision (--watch):
451
1405
  // swap the label so the single line reflects the new phase, timer resets.
452
- if (s.status === "reconciled" && !s.shipped && phase !== "ship") {
1406
+ if (!args.json && s.status === "reconciled" && !s.shipped && phase !== "ship") {
453
1407
  phase = "ship";
454
1408
  progress.stop();
455
1409
  progress = startProgress(`reconciled ${short} — waiting for a ship decision…`);
456
1410
  }
457
1411
  },
458
1412
  });
459
- progress.stop();
460
- progress = null;
1413
+ if (progress) {
1414
+ progress.stop();
1415
+ progress = null;
1416
+ }
461
1417
  }
462
- reportStatus(status, tenant, { open: !args.noOpen });
1418
+ // --json also skips the browser auto-open (open: !args.noOpen && !args.json)
1419
+ // — automation doesn't want a browser popping up.
1420
+ reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit, ref, verb, noChanges: patchEntries.length === 0 });
1421
+ emitJson(args, buildJsonResult({ ok: status?.status !== "failed", ref, commit, changeId, candidate, status, previewPrUrl }));
463
1422
  return status?.status === "failed" ? 1 : 0;
464
1423
  } catch (e) {
465
1424
  progress?.stop();
466
- if (e instanceof AuthUnavailableError) {
467
- console.log(` (sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"})`);
468
- } else {
469
- console.log(` (reconcile is running — the result read-back isn't available yet: ${String(e?.message || e)})`);
1425
+ const note = e instanceof AuthUnavailableError
1426
+ ? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
1427
+ : `reconcile is running — the result read-back isn't available yet: ${describeReadbackError(e)}`;
1428
+ if (!args.json) {
1429
+ console.log(` (${note})`);
1430
+ console.log(` Your push is in; the preview updates once reconcile completes.`);
470
1431
  }
471
- console.log(` Your push is in; the preview updates once reconcile completes.`);
1432
+ emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
472
1433
  return 0;
473
1434
  }
474
1435
  }
@@ -476,20 +1437,31 @@ export async function run(argv, ctx) {
476
1437
  /**
477
1438
  * Normalize a `preview_status` tool response to the contract shape the CLI reports
478
1439
  * on: { status, reconcile:{ok,errors}, compliance:{verdict,detail}, previewUrl,
479
- * shipped }. A live tool always returns status pending|reconciled|failed; anything
480
- * else (an older MCP without the tool's flat fields) normalizes to "unknown" so the
481
- * CLI degrades visibly instead of pretending it reconciled. `shipped` (E1b) is
482
- * null until change_accept ships this exact commit. Pure — unit-tested.
1440
+ * shipped, delivery, dispatched }. A live tool always returns status
1441
+ * pending|reconciled|failed; anything else (an older MCP without the tool's flat
1442
+ * fields) normalizes to "unknown" so the CLI degrades visibly instead of pretending
1443
+ * it reconciled. `shipped` (E1b) is null until change_accept ships this exact commit.
1444
+ *
1445
+ * `delivery` is the MCP's reconcile-observability triad for the Gitea webhook that
1446
+ * fired on this commit ({ target, actual, evidence, drift? }) — or null when NO
1447
+ * webhook delivery was observed for this (tenant, commit). `dispatched` distills that
1448
+ * to a boolean: a `pending` status with `dispatched === false` means the reconcile
1449
+ * was NEVER DISPATCHED (no webhook fired — e.g. an unregistered hook, or a commit
1450
+ * read under a different tenant scope than it was pushed to), which is a permanent
1451
+ * dead-end the CLI must not report as "still reconciling". Pure — unit-tested.
483
1452
  */
484
1453
  export function normalizePreviewStatus(r) {
485
1454
  const status = r?.status;
486
1455
  const known = status === "pending" || status === "reconciled" || status === "failed";
1456
+ const delivery = r?.delivery ?? null;
487
1457
  return {
488
1458
  status: known ? status : "unknown",
489
1459
  reconcile: r?.reconcile ?? null,
490
1460
  compliance: r?.compliance ?? null,
491
1461
  previewUrl: r?.previewUrl ?? null,
492
1462
  shipped: r?.shipped ?? null,
1463
+ delivery,
1464
+ dispatched: delivery != null,
493
1465
  raw: r,
494
1466
  };
495
1467
  }
@@ -506,29 +1478,67 @@ export function normalizePreviewStatus(r) {
506
1478
  * the pre-E2 fixed-interval poll — no version check needed, the fallback is
507
1479
  * automatic. Stops as soon as status resolves to "failed"/"unknown" (a failed
508
1480
  * reconcile can't ship), or resolves to "reconciled" AND (not untilShipped, or
509
- * already shipped). Injectable delay/attempts/waitMs for tests.
1481
+ * already shipped).
1482
+ *
1483
+ * NEVER-DISPATCHED short-circuit (the "still reconciling forever" fix): a `pending`
1484
+ * status with NO delivery ever observed for this commit means no reconcile job was
1485
+ * ever dispatched (unregistered webhook, or a tenant-scope mismatch on the read) —
1486
+ * it will never resolve. Rather than walk the whole (~8 min under --watch) budget
1487
+ * lying about progress, once we're past a short startup grace (`notDispatchedGraceMs`
1488
+ * — long enough for a real delivery record to land after the push) with the delivery
1489
+ * still absent, we stop and return the honest state tagged `notDispatched: true`. If
1490
+ * a delivery IS seen we keep polling as before (dispatched, just slow), and a plain
1491
+ * timeout while still pending is tagged `notDispatched` only when a delivery was never
1492
+ * observed. Injectable delay/attempts/waitMs/grace + a `now` clock for tests.
510
1493
  * @param {{callTool:Function}} client
511
1494
  * @param {string} commit
512
- * @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean, onTick?: (s:object,i:number)=>void }} [opts]
1495
+ * @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean,
1496
+ * notDispatchedGraceMs?: number, now?: () => number, onTick?: (s:object,i:number)=>void }} [opts]
513
1497
  */
514
1498
  export async function pollPreviewStatus(
515
1499
  client,
516
1500
  commit,
517
- { attempts = 8, delayMs = 2500, waitMs = delayMs, untilShipped = false, onTick } = {},
1501
+ { attempts = 8, delayMs = 2500, waitMs = delayMs, untilShipped = false, notDispatchedGraceMs = 15_000, now = Date.now, onTick } = {},
518
1502
  ) {
519
1503
  let last = null;
1504
+ let everDispatched = false;
1505
+ const pollStart = now();
520
1506
  for (let i = 0; i < attempts; i++) {
521
- const startedAt = Date.now();
1507
+ const startedAt = now();
522
1508
  const args = waitMs ? { commit, waitMs } : { commit };
523
1509
  last = normalizePreviewStatus(await client.callTool("preview_status", args));
1510
+ if (last.dispatched) everDispatched = true;
1511
+ last.everDispatched = everDispatched;
524
1512
  if (onTick) onTick(last, i);
1513
+ // Terminally-failed forward: the delivery record settled with forwarded:false
1514
+ // (and it isn't the at-receipt `pending` marker) — the control plane could not
1515
+ // deliver this commit to the reconciler, and polling longer cannot change that.
1516
+ // Only a NEW push produces a new delivery. Stop and say so (Trello-13075
1517
+ // honesty discipline: never spin on a state that cannot progress).
1518
+ if (
1519
+ last.status === "pending" &&
1520
+ last.delivery?.actual &&
1521
+ last.delivery.actual.forwarded === false &&
1522
+ !last.delivery.actual.pending
1523
+ ) {
1524
+ return { ...last, forwardFailed: true };
1525
+ }
1526
+ // Never-dispatched dead-end: still pending, no delivery has EVER been observed
1527
+ // for this commit, and we're past the startup grace — the reconcile will never
1528
+ // arrive. Return honestly instead of continuing to show "still reconciling".
1529
+ if (last.status === "pending" && !everDispatched && now() - pollStart >= notDispatchedGraceMs) {
1530
+ return { ...last, notDispatched: true };
1531
+ }
525
1532
  const stillWatchingForShip = untilShipped && last.status === "reconciled" && !last.shipped;
526
1533
  if (last.status !== "pending" && !stillWatchingForShip) return last;
527
1534
  if (i < attempts - 1) {
528
- const remaining = delayMs - (Date.now() - startedAt);
1535
+ const remaining = delayMs - (now() - startedAt);
529
1536
  if (remaining > 0) await delay(remaining);
530
1537
  }
531
1538
  }
1539
+ // Budget exhausted. A still-pending result that never saw a delivery is a
1540
+ // never-dispatched dead-end (honest), not "still working".
1541
+ if (last) return { ...last, everDispatched, notDispatched: last.status === "pending" && !everDispatched };
532
1542
  return last;
533
1543
  }
534
1544
 
@@ -548,43 +1558,212 @@ function reportComplianceCheck(c) {
548
1558
  if (c.hint) console.log(` → fix: ${c.hint}`);
549
1559
  }
550
1560
 
1561
+ /**
1562
+ * The IMMEDIATE shareable preview URL (Vercel-style: "the URL exists before the
1563
+ * build does") — composed client-side, deterministically, from the tenant + PR
1564
+ * number the candidate_open call just returned, so a developer gets a link to
1565
+ * paste to a reviewer the INSTANT the candidate opens, not minutes later once
1566
+ * reconcile finishes and the MCP mints `previewUrl` server-side (that's
1567
+ * formatShareableUrlBlock's job, above — the two are deliberately redundant:
1568
+ * this one is available immediately but "building", that one is authoritative
1569
+ * once reconcile actually lands). Same route shape as the server-minted one
1570
+ * (`/preview/<tenant>/pr/<N>`) by construction — see
1571
+ * docs/architecture/preview-candidate-workflow.md — so the link doesn't change
1572
+ * out from under the reviewer once the build completes; it just starts
1573
+ * resolving.
1574
+ * Trims a trailing slash off `base` so a `TOT_STOREFRONT_URL` set WITH or
1575
+ * without one composes identically. Pure — unit-tested.
1576
+ * @param {string} base storefront origin, e.g. https://storefront.tokenoftrust.store
1577
+ * @param {string} tenant
1578
+ * @param {number} prNumber
1579
+ * @returns {string}
1580
+ */
1581
+ export function shareablePrUrl(base, tenant, prNumber) {
1582
+ return `${String(base).replace(/\/+$/, "")}/preview/${tenant}/pr/${prNumber}`;
1583
+ }
1584
+
1585
+ /**
1586
+ * Humanize a result-read-back failure. MCP auth errors arrive as a JSON blob
1587
+ * whose `data.self_repair` carries a summary + step list — dumping that raw
1588
+ * into the terminal (observed live: a wall of escaped JSON mid-submit) buries
1589
+ * the one thing the developer needs: sign in again. Detect that shape and
1590
+ * reduce it to the summary's first sentence + the concrete next step; anything
1591
+ * else passes through unchanged. Pure — unit-tested.
1592
+ * @param {unknown} e
1593
+ * @returns {string}
1594
+ */
1595
+ export function describeReadbackError(e) {
1596
+ const msg = String(e?.message || e || "");
1597
+ const jsonStart = msg.indexOf("{");
1598
+ if (jsonStart >= 0 && msg.includes("self_repair")) {
1599
+ try {
1600
+ const body = JSON.parse(msg.slice(jsonStart));
1601
+ const repair = body?.data?.self_repair;
1602
+ const summaryFirst = String(repair?.summary || body?.message || "").split(/(?<=\.)\s/)[0];
1603
+ if (summaryFirst) {
1604
+ return `${summaryFirst} Next: run \`tot login\` in this shell (check TOT_PROFILE), then re-run.`;
1605
+ }
1606
+ } catch {
1607
+ // Not the shape we thought — fall through to the raw message.
1608
+ }
1609
+ }
1610
+ return msg;
1611
+ }
1612
+
1613
+ /**
1614
+ * Build the printed lines for the headline "share this with your reviewer" block —
1615
+ * the whole point of U14: on a successful preview, the SHAREABLE deep link
1616
+ * (`https://storefront.tokenoftrust.store/preview/<tenant>/pr/<N>`, built server-side
1617
+ * by the reconcile report and threaded through as `previewUrl`) is what a developer
1618
+ * hands to a teammate, NOT the internal Gitea PR url `reportCandidate` prints earlier
1619
+ * in the flow (step 2b) — that one stays as-is, secondary, for the developer's own
1620
+ * reference. Labeled prominently + paired with an honest auth caveat: the share
1621
+ * target is a TEAMMATE WITH STORE ACCESS (member/staff of the tenant), never a
1622
+ * public/anonymous link.
1623
+ *
1624
+ * Degrades gracefully when `previewUrl` isn't (yet) on the status result — an older
1625
+ * MCP, or a reconcile that hasn't finished minting it — with a note instead of a
1626
+ * crash or a silent blank. Pure — unit-tested.
1627
+ * @param {{ previewUrl?: string|null, status?: string }|null} s
1628
+ * @param {string} tenant
1629
+ * @returns {string[]}
1630
+ */
1631
+ export function formatShareableUrlBlock(s, tenant) {
1632
+ if (s?.previewUrl) {
1633
+ return [
1634
+ `\n ✓ Preview ready — share this with your reviewer:`,
1635
+ ` ${s.previewUrl}`,
1636
+ ` ℹ your reviewer needs store access (a member/staff of ${tenant}) to view it — it's not a public link.`,
1637
+ ];
1638
+ }
1639
+ if (s?.status === "reconciled") {
1640
+ return [`\n ~ reconciled, but no shareable preview URL yet — it'll show up here once available.`];
1641
+ }
1642
+ return [];
1643
+ }
1644
+
1645
+ /**
1646
+ * The honest "no reconcile job was dispatched" block — printed when a preview stays
1647
+ * `pending` with no webhook delivery ever observed for the commit (pollPreviewStatus
1648
+ * tagged it `notDispatched`). This replaces the old "reconcile still running — check
1649
+ * back / re-submit" lie for the dead-end case: re-submitting cannot help, so we say
1650
+ * what actually happened and what to do, and never recommend another submit. Pure —
1651
+ * unit-tested. `verb` brands the copy with whatever the developer typed.
1652
+ * @param {{ commit?: string|null, ref?: string|null }} ctx
1653
+ * @param {string} tenant @param {string} [verb]
1654
+ * @returns {string[]}
1655
+ */
1656
+ export function formatNotDispatchedBlock({ commit = null, ref = null, noChanges = false } = {}, tenant, verb = "preview") {
1657
+ const short = commit ? commit.slice(0, 9) : "(unknown commit)";
1658
+ // Empty-diff cause FIRST when we know it applies (live-testing finding: an
1659
+ // empty candidate submit structurally CANNOT build — no diff → no candidate PR
1660
+ // → no pull_request webhook — and blaming webhooks/scope for it sent a human
1661
+ // down two wrong debugging paths).
1662
+ const causes = [
1663
+ ...(noChanges
1664
+ ? [` • 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\`),`]
1665
+ : []),
1666
+ ` • the store's reconcile webhook isn't registered yet (an operator must (re-)provision it), or`,
1667
+ ` • your session is scoped to a different store than the one you pushed.`,
1668
+ ];
1669
+ return [
1670
+ `\n ⚠ No reconcile was dispatched for ${short} on ${tenant}.`,
1671
+ ` Your push landed${ref ? ` on ${ref}` : ""}, but nothing picked it up to build a preview —`,
1672
+ ` re-running \`tot ${verb}\` will NOT change that. This usually means one of:`,
1673
+ ...causes,
1674
+ ` Next:`,
1675
+ ` • \`tot grants\` — confirm ${tenant} is active for you;`,
1676
+ ` • check the preview dashboard for ${tenant} (it will read "Last reconcile: never" until a job runs);`,
1677
+ ` • if it stays "never", share this with support: commit ${short}, tenant ${tenant}${ref ? `, ref ${ref}` : ""}.`,
1678
+ ];
1679
+ }
1680
+
1681
+ /**
1682
+ * The honest "the delivery FAILED to forward" block — the delivery record settled
1683
+ * `forwarded: false` (not the at-receipt pending marker), so the control plane
1684
+ * could not deliver this commit to the reconciler; polling longer cannot change
1685
+ * that, and ONLY a new push produces a new delivery. Distinct from
1686
+ * `formatNotDispatchedBlock` (nothing was ever dispatched) — here the plumbing
1687
+ * fired and died in transit, so the recovery differs. Pure — unit-tested.
1688
+ * @param {{ commit?: string|null }} ctx @param {string} tenant
1689
+ * @returns {string[]}
1690
+ */
1691
+ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
1692
+ const short = commit ? commit.slice(0, 9) : "(unknown commit)";
1693
+ return [
1694
+ `\n ⚠ The reconcile delivery for ${short} on ${tenant} FAILED in transit (network/timeout at the control plane).`,
1695
+ ` Waiting longer will not help — only a NEW push produces a new delivery.`,
1696
+ ` Next: commit again (an empty commit works: git commit --allow-empty -m retry) and re-push;`,
1697
+ ` if it fails the same way twice, share this with support: commit ${short}, tenant ${tenant}.`,
1698
+ ];
1699
+ }
1700
+
551
1701
  /**
552
1702
  * Print the reconcile/compliance/preview result and, on a clean reconcile with a
553
- * preview URL, open it in the browser (unless opts.open === false).
1703
+ * preview URL, open it in the browser (unless opts.open === false). `quiet`
1704
+ * (--json) suppresses ALL printing here — the browser open still runs unless
1705
+ * the caller also passes `open: false` (run() passes `open: false` under
1706
+ * --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
1707
+ * feed the honest never-dispatched block.
554
1708
  */
555
- function reportStatus(s, tenant, { open = true } = {}) {
1709
+ function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview", noChanges = false } = {}) {
556
1710
  if (!s || s.status === "unknown") {
557
- console.log(
558
- ` (this MCP doesn't return the per-commit reconcile result yet — your push is in;\n` +
559
- ` the preview updates once reconcile runs. Check the preview dashboard.)`,
560
- );
1711
+ if (!quiet) {
1712
+ console.log(
1713
+ ` (this MCP doesn't return the per-commit reconcile result yet your push is in;\n` +
1714
+ ` the preview updates once reconcile runs. Check the preview dashboard.)`,
1715
+ );
1716
+ }
561
1717
  return;
562
1718
  }
563
- if (s.status === "pending") {
564
- console.log(` reconcile still running for ${tenant} check back shortly (re-run \`tot submit --no-wait\`).`);
1719
+ // Terminally-failed forward the delivery fired and died in transit; a re-push
1720
+ // (new delivery) is the only recovery. Checked BEFORE notDispatched: a settled
1721
+ // failed forward IS a dispatch, just a doomed one.
1722
+ if (s.forwardFailed) {
1723
+ if (!quiet) for (const line of formatForwardFailedBlock({ commit }, tenant)) console.log(line);
565
1724
  return;
566
1725
  }
567
- const rc = s.reconcile;
568
- if (rc) {
569
- if (rc.ok) console.log(` ✓ reconcile ok`);
570
- else {
571
- console.log(` ✗ reconcile failed:`);
572
- for (const e of rc.errors ?? []) console.log(` [${e.rule ?? "?"}] ${e.message ?? ""}`);
573
- }
574
- }
575
- if (s.compliance?.verdict) {
576
- console.log(` compliance: ${s.compliance.verdict}${s.compliance.detail ? ` — ${s.compliance.detail}` : ""}`);
577
- for (const c of s.compliance.checks ?? []) reportComplianceCheck(c);
1726
+ // Never-dispatched dead-end — the honest replacement for false "still reconciling".
1727
+ if (s.notDispatched) {
1728
+ if (!quiet) for (const line of formatNotDispatchedBlock({ commit, ref, noChanges }, tenant, verb)) console.log(line);
1729
+ return;
578
1730
  }
579
- if (s.shipped) {
580
- console.log(`\n ✓ shipped — change ${s.shipped.changeId} accepted at ${s.shipped.shippedAt}`);
581
- } else if (s.status === "reconciled") {
582
- console.log(`\n ~ not yet shippeda reviewer still needs to run change_accept.`);
1731
+ if (s.status === "pending") {
1732
+ if (!quiet) {
1733
+ // Dispatched but not yet reported (real slow reconcile) vs. no job seen yet on
1734
+ // a --no-wait snapshotsay which, and never claim progress we can't see.
1735
+ if (s.dispatched === false) {
1736
+ console.log(` no reconcile job seen yet for ${tenant} — if it doesn't appear shortly, run \`tot grants\` / check the dashboard.`);
1737
+ } else {
1738
+ console.log(` reconcile still running for ${tenant} — check back shortly (re-run \`tot submit --no-wait\`).`);
1739
+ if (s.delivery?.drift) {
1740
+ console.log(` ⚠ a reconcile report exists for a DIFFERENT commit than you pushed — possible tenant-scope mismatch (\`tot grants\` to check your active store).`);
1741
+ }
1742
+ }
1743
+ }
1744
+ return;
583
1745
  }
584
- if (s.previewUrl) {
585
- console.log(`\n Preview: ${s.previewUrl}`);
586
- if (open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
587
- console.log(" (opened in your browser)");
1746
+ if (!quiet) {
1747
+ const rc = s.reconcile;
1748
+ if (rc) {
1749
+ if (rc.ok) console.log(` reconcile ok`);
1750
+ else {
1751
+ console.log(` ✗ reconcile failed:`);
1752
+ for (const e of rc.errors ?? []) console.log(` [${e.rule ?? "?"}] ${e.message ?? ""}`);
1753
+ }
1754
+ }
1755
+ if (s.compliance?.verdict) {
1756
+ console.log(` compliance: ${s.compliance.verdict}${s.compliance.detail ? ` — ${s.compliance.detail}` : ""}`);
1757
+ for (const c of s.compliance.checks ?? []) reportComplianceCheck(c);
588
1758
  }
1759
+ if (s.shipped) {
1760
+ console.log(`\n ✓ shipped — change ${s.shipped.changeId} accepted at ${s.shipped.shippedAt}`);
1761
+ } else if (s.status === "reconciled") {
1762
+ console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
1763
+ }
1764
+ for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
1765
+ }
1766
+ if (s.previewUrl && open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
1767
+ if (!quiet) console.log(" (opened in your browser)");
589
1768
  }
590
1769
  }