@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.20

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