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

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.
@@ -40,12 +40,17 @@
40
40
  * Dependency-free (global fetch + `git`).
41
41
  */
42
42
  import { execFileSync } from "node:child_process";
43
- import { readFileSync } from "node:fs";
43
+ import { readFileSync, existsSync } from "node:fs";
44
+ import { resolve as resolvePath } from "node:path";
44
45
  import { createHash } from "node:crypto";
45
46
  import { setTimeout as delay } from "node:timers/promises";
46
47
  import { createMcpClient } from "../mcp.mjs";
47
48
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
48
49
  import { checkoutTenant } from "./clone.mjs";
50
+ // Reuse the operator side's `candidate_refresh` result normaliser (unit c3 — the
51
+ // born-rebased submit rebuilds a candidate onto the current base with the SAME
52
+ // engine `tot accept --refresh` uses, so the two read its result identically).
53
+ import { normalizeRefreshResult } from "./accept.mjs";
49
54
  import { validateTenant, ERROR } from "../validate.mjs";
50
55
  import { openBrowser } from "../open.mjs";
51
56
  import { startProgress } from "../progress.mjs";
@@ -55,6 +60,7 @@ import {
55
60
  defaultCandidateStatePath,
56
61
  readActiveChangeId,
57
62
  writeActiveChangeId,
63
+ clearActiveChangeId,
58
64
  mintFreshChangeId,
59
65
  isTerminalCandidateState,
60
66
  isDefaultBranch,
@@ -90,6 +96,8 @@ export function candidateRefFor(changeId) {
90
96
  * value). Fire-and-forget best-effort: a silent no-op without a hosted-bridge
91
97
  * credential, never awaited, never throws, never alters the command. `errorClass` is
92
98
  * a low-cardinality class (never a raw git stderr, which can carry a token/path).
99
+ * @param {string} op @param {boolean} ok
100
+ * @param {{ command?: string, durationMs?: number, errorClass?: string }} [opts]
93
101
  */
94
102
  function emitGitOp(op, ok, { command = "submit", durationMs, errorClass } = {}) {
95
103
  void emitActivity({
@@ -103,7 +111,8 @@ export function parseArgs(argv) {
103
111
  // `ref: null` — an explicit `--ref` always wins; otherwise the push target is
104
112
  // derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
105
113
  // 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 };
114
+ /** @type {{ mcp: string|null, identity: string|null, ref: string|null, skipValidate: boolean, skipFreshness: boolean, noWait: boolean, watch: boolean, noOpen: boolean, noCommit: boolean, message: string|null, summary: string|null, summaryFile: string|null, strategy: string|null, json: boolean, forkCandidate: boolean, help: boolean }} */
115
+ const a = { mcp: null, identity: null, ref: null, skipValidate: false, skipFreshness: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, summaryFile: null, strategy: null, json: false, forkCandidate: false, help: false };
107
116
  for (let i = 0; i < argv.length; i++) {
108
117
  const t = argv[i];
109
118
  if (t === "--mcp") a.mcp = argv[++i];
@@ -112,13 +121,15 @@ export function parseArgs(argv) {
112
121
  else if (t === "-m" || t === "--message") a.message = argv[++i];
113
122
  else if (t === "--summary") a.summary = argv[++i];
114
123
  else if (t === "--summary-file") a.summaryFile = argv[++i];
124
+ else if (t === "--strategy") a.strategy = argv[++i];
115
125
  else if (t === "--json") a.json = true;
116
126
  else if (t === "--skip-validate") a.skipValidate = true;
127
+ else if (t === "--skip-freshness") a.skipFreshness = true;
117
128
  else if (t === "--no-commit") a.noCommit = true;
118
129
  else if (t === "--no-wait") a.noWait = true;
119
130
  else if (t === "--watch") a.watch = true;
120
131
  else if (t === "--no-open") a.noOpen = true;
121
- else if (t === "--new") a.new = true;
132
+ else if (t === "--fork-candidate") a.forkCandidate = true;
122
133
  else if (t === "--help" || t === "-h") a.help = true;
123
134
  }
124
135
  return a;
@@ -134,9 +145,20 @@ export function renderUsage(verb = "preview") {
134
145
  return `tot ${verb} — submit your store for preview
135
146
 
136
147
  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
148
+ tot ${verb} --fork-candidate open a SECOND, independently-tracked candidate (a
149
+ parallel dev path). Rarely needed — a fresh git
150
+ branch already gets its own candidate automatically
151
+ (git checkout is the PR switcher); use this only to
152
+ run two candidates from ONE branch.
138
153
  tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
139
154
  tot ${verb} --skip-validate push without the local lint (not recommended)
155
+ tot ${verb} --skip-freshness skip the stale-base check AND the born-rebased rebuild
156
+ (not recommended — may build a candidate rooted in an
157
+ already-superseded base)
158
+ tot ${verb} --strategy <s> how the born-rebased rebuild resolves a file changed on
159
+ BOTH sides when the base has moved: "merge" (real 3-way
160
+ merge, surfaces a resolve card on a genuine overlap — the
161
+ default), "ours" (keep yours), "theirs" (keep the store's)
140
162
  tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
141
163
  tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
142
164
  tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
@@ -154,11 +176,15 @@ export function renderUsage(verb = "preview") {
154
176
  tot ${verb} --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
155
177
 
156
178
  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
179
+ to a GitHub PR), rather than opening a new one each time. Manage your open
180
+ candidates with \`tot pr\` (list / view / close). If your candidate was already
160
181
  merged or closed, a re-run automatically opens a fresh one.
161
182
 
183
+ Starting a separate change? \`git checkout -b <branch>\` — a fresh branch gets its
184
+ own candidate automatically, and \`git checkout\` back and forth is how you switch
185
+ between them. --fork-candidate is an escape hatch for the rarer case of wanting a
186
+ SECOND candidate off the SAME branch; reach for a branch first.
187
+
162
188
  Once a preview reconciles cleanly, \`tot ship\` promotes it live.
163
189
 
164
190
  If you omit -m, a summary is generated from git (commit subject + the diff vs
@@ -326,6 +352,180 @@ export function readSummaryFileContent(pathOrDash) {
326
352
  return readFileSync(pathOrDash, "utf8");
327
353
  }
328
354
 
355
+ // ─── stale-base freshness preflight (unit u16) ──────────────────────────────────
356
+
357
+ /** The candidate base branch every submit targets (mirrors tot-mcp's own
358
+ * `DEFAULT_CANDIDATE_BASE` and sync.mjs's `DEFAULT_SYNC_BRANCH` — the SAME
359
+ * protected branch by three different names in three different modules; kept
360
+ * a local constant here, not imported, since this file already resolves its
361
+ * own defaults independently of sync.mjs and candidate_open's server default). */
362
+ export const FRESHNESS_BASE_BRANCH = "preview";
363
+
364
+ /**
365
+ * Detect a STALE local view of the base branch before minting a candidate —
366
+ * the live-repeat incident this guards against: the checkout's `origin/preview`
367
+ * tracking ref was stale (recorded before a just-merged PR moved it), so a
368
+ * fresh `tot preview --fork-candidate` built a candidate rooted in the OLD tip and got an
369
+ * instant, entirely avoidable "not mergeable" the moment it was compared
370
+ * against the real, already-advanced `preview`.
371
+ *
372
+ * Compares what the LOCAL checkout believes the base's tip is
373
+ * (`refs/remotes/origin/<branch>`, only as fresh as the last explicit fetch)
374
+ * against its ACTUAL current tip on the forge (`git ls-remote`, a lightweight
375
+ * single-ref read — no full fetch, no local ref mutated). Returns the live
376
+ * remote sha when local's cached view is behind it, or `null` when: the two
377
+ * already agree, there is no local tracking ref to compare against yet (a
378
+ * checkout that has simply never fetched this branch — never a false block on
379
+ * that), or the remote can't be reached right now (a network hiccup must
380
+ * never block a submit that would otherwise succeed; the push itself is the
381
+ * real connectivity test). Pure git I/O via the injected runner — unit-tested.
382
+ * @param {(cargs:string[])=>string} git
383
+ * @param {string} branch
384
+ * @returns {string|null}
385
+ */
386
+ export function detectStaleBase(git, branch) {
387
+ let localSha = "";
388
+ try {
389
+ localSha = git(["rev-parse", "-q", "--verify", `refs/remotes/origin/${branch}`]).trim();
390
+ } catch {
391
+ return null; // never fetched this branch locally — nothing cached to be stale
392
+ }
393
+ if (!localSha) return null;
394
+ let remoteSha = "";
395
+ try {
396
+ remoteSha = (git(["ls-remote", "origin", branch]).split(/\s+/)[0] || "").trim();
397
+ } catch {
398
+ return null; // can't reach the remote right now — don't block on a network hiccup
399
+ }
400
+ if (!remoteSha || remoteSha === localSha) return null;
401
+ return remoteSha;
402
+ }
403
+
404
+ /**
405
+ * How many commits the base branch has advanced since this candidate forked off it
406
+ * (unit c2 — the shift-left base-drift warning). Conflicts in this loop are BASE
407
+ * DRIFT: a candidate branches off `<base>`, sits while OTHER candidates advance
408
+ * `<base>`, then settles mergeable=false at Accept time. This measures that drift
409
+ * cheaply and LOCALLY — the merge-base (fork point) of HEAD and the base's
410
+ * remote-tracking tip, then how many commits separate that fork point from the tip.
411
+ *
412
+ * PURE LOCAL git — reads `refs/remotes/origin/<branch>` (the last-fetched tip),
413
+ * never the network; it complements detectStaleBase (which confirms that tracking
414
+ * ref is itself current). Returns 0 — never a false warning — when there's nothing
415
+ * to compare against or the drift can't be positively determined: no base tracking
416
+ * ref yet, unrelated histories / no merge-base, HEAD already contains the tip, or
417
+ * any git failure. Non-blocking by contract: the caller warns on a positive count
418
+ * but always proceeds. Pure git I/O via the injected runner — unit-tested.
419
+ * @param {(cargs:string[])=>string} git
420
+ * @param {string} branch
421
+ * @returns {number}
422
+ */
423
+ export function baseCommitsBehind(git, branch) {
424
+ const baseTip = `refs/remotes/origin/${branch}`;
425
+ let tip = "";
426
+ try {
427
+ tip = git(["rev-parse", "-q", "--verify", baseTip]).trim();
428
+ } catch {
429
+ return 0; // no local tracking ref for the base — nothing to compare against
430
+ }
431
+ if (!tip) return 0;
432
+ let mergeBase = "";
433
+ try {
434
+ mergeBase = git(["merge-base", "HEAD", baseTip]).trim();
435
+ } catch {
436
+ return 0; // unrelated histories / no HEAD — nothing meaningful to count
437
+ }
438
+ if (!mergeBase || mergeBase === tip) return 0; // HEAD already contains the base tip
439
+ try {
440
+ const n = parseInt(git(["rev-list", "--count", `${mergeBase}..${baseTip}`]).trim(), 10);
441
+ return Number.isFinite(n) && n > 0 ? n : 0;
442
+ } catch {
443
+ return 0;
444
+ }
445
+ }
446
+
447
+ // ─── born-rebased at submit (unit c3 — shift-left prevention #2) ─────────────────
448
+
449
+ /** The three strategies the born-rebased rebuild accepts, mirroring `tot accept
450
+ * --refresh` (accept.mjs) and the admin one-click resolver: `merge` (real 3-way,
451
+ * refuses on a genuine overlap), `ours` (keep yours), `theirs` (keep the store's).
452
+ * `merge` is the DEFAULT — auto-rebuild on pure drift, name the conflict on a real
453
+ * same-line overlap, never silently clobber a side. */
454
+ export const BORN_REBASED_STRATEGIES = ["ours", "theirs", "merge"];
455
+ export const DEFAULT_BORN_REBASED_STRATEGY = "merge";
456
+
457
+ /**
458
+ * Run `candidate_refresh` (the born-rebased rebuild, unit c3) over the ALREADY
459
+ * established MCP client — the candidate was just opened by candidate_open (step 2b)
460
+ * and the tenant scope is already bound, so this reuses that session rather than
461
+ * establishing its own (accept.mjs's runRefresh is the operator entry that does the
462
+ * sign-in; here the submit flow already holds the session). Rebuilds the candidate
463
+ * from the CURRENT base tip and re-applies its file changes under `strategy`. Returns
464
+ * the normalized result (status:"committed" is the only success). Best-effort: any
465
+ * throw — an owner-gated denial (candidate_refresh is app-owner gated), an older MCP
466
+ * without the tool, a transient failure — normalizes to a non-ok error result the
467
+ * caller submits-as-is on, NEVER blocking the push that already landed.
468
+ * @param {{callTool:Function}} client
469
+ * @param {{ repo: string, changeId: string, strategy: string }} opts
470
+ * @returns {Promise<ReturnType<typeof normalizeRefreshResult>>}
471
+ */
472
+ export async function runBornRebased(client, { repo, changeId, strategy }) {
473
+ try {
474
+ const raw = await client.callTool("candidate_refresh", { repo, changeId, strategy });
475
+ return normalizeRefreshResult(raw);
476
+ } catch (e) {
477
+ return { ...normalizeRefreshResult(null), status: "error", message: String(e?.message || e) };
478
+ }
479
+ }
480
+
481
+ /**
482
+ * The born-rebased SUCCESS lines (unit c3) — printed when the candidate rebuilt
483
+ * cleanly onto the current base tip, so it enters the queue already mergeable rather
484
+ * than settling not-mergeable at Accept time. `behind` is the base-drift count that
485
+ * triggered the rebuild. Pure — unit-tested.
486
+ * @param {{ behind?: number, strategy?: string, refreshedFiles?: string[] }} input
487
+ * @returns {string[]}
488
+ */
489
+ export function formatBornRebasedSuccess({ behind = 0, strategy = DEFAULT_BORN_REBASED_STRATEGY, refreshedFiles = [] } = {}) {
490
+ const drift = behind > 0 ? `${behind} commit${behind === 1 ? "" : "s"}` : "since you forked";
491
+ const lines = [
492
+ `\n ✓ rebuilt fresh on the current store (base had advanced ${drift}, strategy ${strategy}) — your candidate enters the queue already mergeable.`,
493
+ ];
494
+ if (refreshedFiles.length) lines.push(` reapplied: ${refreshedFiles.join(", ")}`);
495
+ return lines;
496
+ }
497
+
498
+ /**
499
+ * The born-rebased RESOLVE CARD (unit c3) — printed when the default `merge` rebuild
500
+ * hits a GENUINE same-line overlap with the base (not mere drift): the biggest single
501
+ * shift-left win is surfacing that conflict HERE, at submit, instead of letting it
502
+ * land as a stuck queue row someone discovers at Accept time. Names the diverged files
503
+ * and offers the two one-command resolutions (keep-mine / keep-store's) — `merge` is
504
+ * the default that just failed, so it isn't re-offered. Matches how the rest of this
505
+ * file reports next-steps (a `✗` headline + concrete `tot <verb> …` commands), never a
506
+ * raw git rebase instruction. Pure — unit-tested. `verb` brands the copy with whatever
507
+ * the developer typed.
508
+ * @param {{ unresolved?: string[] }} rr
509
+ * @param {string} [verb]
510
+ * @returns {string[]}
511
+ */
512
+ export function formatBornRebasedConflict({ unresolved = [] } = {}, verb = "preview") {
513
+ const lines = [
514
+ `\n ✗ your change conflicts with the current store on the same lines — it can't be auto-rebased.`,
515
+ ];
516
+ if (unresolved.length) {
517
+ lines.push(` These files changed on both sides since you forked and need your call:`);
518
+ for (const f of unresolved) lines.push(` - ${f}`);
519
+ }
520
+ lines.push(
521
+ ` Resolve it in one command — choose which side wins on those files:`,
522
+ ` tot ${verb} --strategy=ours keep YOURS on any clash`,
523
+ ` tot ${verb} --strategy=theirs keep the STORE's on any clash`,
524
+ ` Your push is in; the candidate is submitted but stays not-mergeable until you resolve it.`,
525
+ );
526
+ return lines;
527
+ }
528
+
329
529
  // ─── auto-commit the known content trees (unit u2) ───────────────────────────────
330
530
 
331
531
  /**
@@ -410,6 +610,59 @@ export function buildAutoCommitMessage({ message, files = [], statLine = "" } =
410
610
  return body.length ? `${subject}\n\n${body.join("\n")}` : subject;
411
611
  }
412
612
 
613
+ /**
614
+ * Detect an in-progress rebase, merge, or cherry-pick in the workspace (unit u8) —
615
+ * `tot preview`/`tot submit` must NEVER auto-commit over one of these. A rebase that
616
+ * stopped at "Could not apply" (or a merge/cherry-pick left with real conflicts) IS
617
+ * a dirty tree from `git status`'s point of view, so `autoCommitKnownTrees` would
618
+ * otherwise stage + commit the half-resolved content straight into a plain "content
619
+ * update" commit — silently finishing the git operation WRONG and losing whatever
620
+ * edit was still sitting in conflict markers or unresolved hunks (the live incident
621
+ * this guards against: a stalled rebase was never continued, `tot preview` ran
622
+ * anyway, and the developer's own edit was gone). Detection is worktree-safe:
623
+ * MERGE_HEAD/CHERRY_PICK_HEAD via plumbing refs (no direct `.git` path assumption),
624
+ * and rebase-merge/rebase-apply via `--git-path` (a linked worktree's git-dir lives
625
+ * OUTSIDE `<workspace>/.git`, so a literal `.git/rebase-merge` check would miss it).
626
+ * Returns which operation is in progress, or null when the tree is clean of one.
627
+ * @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
628
+ * @param {string} workspace absolute path `git` runs `-C` against — `--git-path`'s
629
+ * output may be relative, and Node's `existsSync` resolves relative paths against
630
+ * the CLI process's own cwd, not the checkout, so this pins the resolution base.
631
+ * @returns {"rebase"|"merge"|"cherry-pick"|null}
632
+ */
633
+ export function detectInProgressGitOperation(git, workspace) {
634
+ const hasRef = (name) => {
635
+ try {
636
+ return git(["rev-parse", "-q", "--verify", name]).trim().length > 0;
637
+ } catch {
638
+ return false;
639
+ }
640
+ };
641
+ if (hasRef("MERGE_HEAD")) return "merge";
642
+ if (hasRef("CHERRY_PICK_HEAD")) return "cherry-pick";
643
+ const hasGitPath = (name) => {
644
+ try {
645
+ const p = git(["rev-parse", "--git-path", name]).trim();
646
+ // An empty result should never happen for a real `--git-path` (it always
647
+ // echoes SOME path, existing or not) — but treat it as "absent" rather than
648
+ // resolving it, since `resolvePath(workspace, "")` degrades to `workspace`
649
+ // itself, which trivially always exists (a false "rebase in progress" on
650
+ // every call, not just an occasional false negative).
651
+ return p.length > 0 && existsSync(resolvePath(workspace, p));
652
+ } catch {
653
+ return false;
654
+ }
655
+ };
656
+ if (hasGitPath("rebase-merge") || hasGitPath("rebase-apply")) return "rebase";
657
+ return null;
658
+ }
659
+
660
+ /** The abort command that recovers from each in-progress git operation, so the
661
+ * refusal below can tell a developer exactly what to run. Pure. */
662
+ export function abortCommandFor(op) {
663
+ return op === "merge" ? "git merge --abort" : op === "cherry-pick" ? "git cherry-pick --abort" : "git rebase --abort";
664
+ }
665
+
413
666
  /**
414
667
  * Auto-commit the known content trees before previewing (unit u2). On a DIRTY
415
668
  * tree `tot preview` commits your content edits for you, so a preview always
@@ -420,16 +673,21 @@ export function buildAutoCommitMessage({ message, files = [], statLine = "" } =
420
673
  * (preview whatever's already committed — u1's behavior).
421
674
  *
422
675
  * 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.
676
+ * { skipped: true } — --no-commit.
677
+ * { inProgress: "rebase"|… } a rebase/merge/cherry-pick is unresolved;
678
+ * caller refuses rather than auto-committing
679
+ * over the developer's own half-resolved tree.
680
+ * { clean: true } — nothing dirty; preview HEAD as-is.
681
+ * { refused, unknown, known } — out-of-scope dirt; caller refuses + hints.
682
+ * { committed: true, sha, files } — staged the known dirty paths and committed.
427
683
  *
428
684
  * @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
429
- * @param {{ message?: string|null, noCommit?: boolean }} [opts]
685
+ * @param {{ message?: string|null, noCommit?: boolean, workspace?: string }} [opts]
430
686
  */
431
- export function autoCommitKnownTrees(git, { message = null, noCommit = false } = {}) {
687
+ export function autoCommitKnownTrees(git, { message = null, noCommit = false, workspace = "." } = {}) {
432
688
  if (noCommit) return { skipped: true };
689
+ const inProgress = detectInProgressGitOperation(git, workspace);
690
+ if (inProgress) return { inProgress };
433
691
  const status = git(["-c", "core.quotePath=false", "status", "--porcelain", "--untracked-files=all"]);
434
692
  const dirty = parsePorcelainPaths(status);
435
693
  if (dirty.length === 0) return { clean: true };
@@ -484,16 +742,17 @@ export function parseNameStatus(text) {
484
742
  * @returns {Array<{path: string, content?: string, contentEncoding?: "base64", delete?: true}>}
485
743
  */
486
744
  export function buildFilePatch(entries, readBlob) {
745
+ /** @type {Array<{ path: string, content?: string, contentEncoding?: "base64", delete?: true }>} */
487
746
  const patch = [];
488
747
  for (const e of entries) {
489
- if (e.status === "R") patch.push({ path: e.from, delete: true });
748
+ if (e.status === "R") patch.push({ path: /** @type {string} */ (e.from), delete: true });
490
749
  if (e.status === "D") {
491
750
  patch.push({ path: e.path, delete: true });
492
751
  continue;
493
752
  }
494
753
  const buf = readBlob(e.path);
495
754
  const asUtf8 = buf.toString("utf8");
496
- const isCleanUtf8 = !asUtf8.includes("") && Buffer.from(asUtf8, "utf8").equals(buf);
755
+ const isCleanUtf8 = !asUtf8.includes("\x00") && Buffer.from(asUtf8, "utf8").equals(buf);
497
756
  patch.push(
498
757
  isCleanUtf8
499
758
  ? { path: e.path, content: asUtf8 }
@@ -523,41 +782,14 @@ export function repoNameFromRemote(remoteUrl) {
523
782
 
524
783
  // ─── fresh-forge-credential push (decision B — the invited-dev 401 dead-end) ─────
525
784
 
526
- /**
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
- }
785
+ // splitAuthedRemote / basicAuthExtraHeader now live in ../git-credential.mjs (unit
786
+ // u10) a dependency-free module BOTH this file and clone.mjs/commands/
787
+ // git-credential.mjs need, so they moved out of here to avoid a submit.mjs
788
+ // clone.mjs import cycle. Re-exported so every existing import of these two names
789
+ // FROM submit.mjs (this file's own callers below, plus tests) keeps working
790
+ // unchanged.
791
+ export { splitAuthedRemote, basicAuthExtraHeader } from "../git-credential.mjs";
792
+ import { splitAuthedRemote, basicAuthExtraHeader, ensureTokenlessRemote } from "../git-credential.mjs";
561
793
 
562
794
  /**
563
795
  * Recognise a forge auth failure (expired / invalid push token) in a failed git
@@ -605,7 +837,7 @@ export function tagFromRepoName(repoName, tenant) {
605
837
  *
606
838
  * @param {(cargs:string[])=>string} git throwing git runner (execFileSync-backed)
607
839
  * @param {() => Promise<string|null>} mintRemote mints a fresh authed gitRemote (null when unavailable)
608
- * @param {{ ref: string }} opts
840
+ * @param {{ ref?: string }} [opts]
609
841
  * @returns {Promise<{ out: string }>} resolves on a successful push; throws (git's error) otherwise
610
842
  */
611
843
  export async function pushPreviewRef(git, mintRemote, { ref } = {}) {
@@ -691,25 +923,79 @@ export function actorKeyFor(session) {
691
923
  * Which candidate this submit lands on (gh-pr-like) — decided UP FRONT, before any
692
924
  * network call, because it also determines the isolated git ref we push to
693
925
  * (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.
926
+ * `--fork-candidate` forks a fresh one.
927
+ * forkCandidate → fork a FRESH candidate id;
928
+ * otherwise → the remembered active candidate (from a prior --fork-candidate /
929
+ * terminal roll), else the STABLE per-dev-per-tenant(-per-branch)
930
+ * default.
698
931
  * `persist` reports whether the choice diverges from the stable default, so the
699
932
  * caller knows whether to remember it as the new active pointer. `mint` is
700
933
  * injected (defaults to mintFreshChangeId) so this is pure/deterministic in tests.
701
934
  * Pure — unit-tested.
702
935
  * @param {{ tenant: string, actorKey: string, branch?: string|null, active?: string|null,
703
- * isNew?: boolean, mint?: (baseId: string) => string }} opts
936
+ * forkCandidate?: boolean, mint?: (baseId: string) => string }} opts
704
937
  * @returns {{ changeId: string, stableId: string, persist: boolean }}
705
938
  */
706
- export function chooseChangeId({ tenant, actorKey, branch = null, active = null, isNew = false, mint = mintFreshChangeId }) {
939
+ export function chooseChangeId({ tenant, actorKey, branch = null, active = null, forkCandidate = false, mint = mintFreshChangeId }) {
707
940
  const stableId = deriveChangeId(tenant, actorKey, branch);
708
- const changeId = isNew ? mint(stableId) : (active || stableId);
709
- const persist = isNew || (!!active && active !== stableId);
941
+ const changeId = forkCandidate ? mint(stableId) : (active || stableId);
942
+ const persist = forkCandidate || (!!active && active !== stableId);
710
943
  return { changeId, stableId, persist };
711
944
  }
712
945
 
946
+ /**
947
+ * The forge state of ONE candidate (`"open"`/`"merged"`/`"closed"`/…), read by
948
+ * changeId via `candidate_status`, or null when it can't be POSITIVELY determined —
949
+ * the candidate isn't found, carries no state, or the read throws. null ("couldn't
950
+ * tell") is the deliberately SAFE answer: the caller (resolveActivePointer) then
951
+ * behaves exactly as if the pointer were still live, so a purely-diagnostic check
952
+ * that can't run never blocks, changes, or crashes a submit (u17 acceptance #3).
953
+ * Tolerates the tool returning a bare candidate, a `{candidates:[…]}` list, or a
954
+ * plain array. Injectable client for tests.
955
+ * @param {{callTool:Function}} client
956
+ * @param {{ repo: string, changeId: string }} opts
957
+ * @returns {Promise<string|null>}
958
+ */
959
+ export async function candidateStateFor(client, { repo, changeId }) {
960
+ try {
961
+ const r = await client.callTool("candidate_status", { repo, changeId });
962
+ const c = Array.isArray(r)
963
+ ? r.find((x) => x?.changeId === changeId)
964
+ : Array.isArray(r?.candidates)
965
+ ? r.candidates.find((x) => x?.changeId === changeId)
966
+ : r;
967
+ return c && typeof c.state === "string" ? c.state : null;
968
+ } catch {
969
+ return null;
970
+ }
971
+ }
972
+
973
+ /**
974
+ * u17 — before REUSING a remembered active-candidate pointer, confirm its PR is
975
+ * still open. The live incident this guards against: after a candidate PR merged, a
976
+ * plain `tot preview` reused the remembered pointer, force-pushed onto the now-DEAD
977
+ * candidate branch (stale old-base history), and `candidate_open` opened a NEW PR
978
+ * from it — inheriting a guaranteed conflict from the very first commit. When the
979
+ * pointer's PR has gone terminal (merged/closed) we DROP it here, so `chooseChangeId`
980
+ * falls back to the stable per-branch id exactly as if no pointer existed.
981
+ *
982
+ * PURELY DIAGNOSTIC — never blocks a submit over the check itself: no pointer, no
983
+ * repo, or a check that errors / can't positively confirm terminal all resolve to
984
+ * `{ active }` UNCHANGED (behave exactly as before). Only a POSITIVELY terminal
985
+ * state drops the pointer. When it does, `dropped` carries the old changeId + the
986
+ * terminal state so the caller can tell the operator and forget the on-disk pointer.
987
+ * Injectable client for tests.
988
+ * @param {{callTool:Function}} client
989
+ * @param {{ repo: string|null, active: string|null }} opts
990
+ * @returns {Promise<{ active: string|null, dropped?: { changeId: string, state: string } }>}
991
+ */
992
+ export async function resolveActivePointer(client, { repo, active }) {
993
+ if (!active || !repo) return { active };
994
+ const state = await candidateStateFor(client, { repo, changeId: active });
995
+ if (isTerminalCandidateState(state)) return { active: null, dropped: { changeId: active, state: /** @type {string} */ (state) } };
996
+ return { active };
997
+ }
998
+
713
999
  /**
714
1000
  * The ref `tot preview`/`tot submit` pushes to (b03 — stop force-pushing the
715
1001
  * SHARED `preview` ref). An explicit `--ref` always wins — the escape hatch /
@@ -836,7 +1122,7 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
836
1122
  shipped: status?.shipped ?? null,
837
1123
  dispatched: status?.dispatched ?? null,
838
1124
  notDispatched: status?.notDispatched ?? false,
839
- forwardFailed: status?.forwardFailed ?? false,
1125
+ forwardFailed: /** @type {any} */ (status)?.forwardFailed ?? false,
840
1126
  delivery: status?.delivery ?? null,
841
1127
  previewPrUrl,
842
1128
  ...(error ? { error } : {}),
@@ -874,6 +1160,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
874
1160
  emitJson(args, buildJsonResult({ ok: false, error: msg }));
875
1161
  return 2;
876
1162
  }
1163
+ // --strategy (c3 born-rebased rebuild) — validate whenever given; defaults to
1164
+ // "merge" (auto-rebuild on drift, surface a resolve card on a genuine overlap).
1165
+ if (args.strategy != null && !BORN_REBASED_STRATEGIES.includes(args.strategy)) {
1166
+ const msg = `unknown --strategy "${args.strategy}"`;
1167
+ console.error(fail(msg, `use one of: ${BORN_REBASED_STRATEGIES.join(", ")} (default "${DEFAULT_BORN_REBASED_STRATEGY}")`));
1168
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1169
+ return 2;
1170
+ }
1171
+ const bornRebasedStrategy = args.strategy || DEFAULT_BORN_REBASED_STRATEGY;
877
1172
  if (args.summaryFile) {
878
1173
  let raw;
879
1174
  try {
@@ -899,6 +1194,63 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
899
1194
  const tenant = ctx.tenant;
900
1195
  const git = (cargs) => execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
901
1196
 
1197
+ // Self-heal a LEGACY checkout (unit u10): strip any token still embedded in
1198
+ // `origin`'s URL and install the credential helper, so this run (and every one
1199
+ // after) mints fresh creds through `tot` instead of relying on one that quietly
1200
+ // expired. Best-effort — never blocks the actual preview on a migration hiccup.
1201
+ try {
1202
+ ensureTokenlessRemote(git);
1203
+ } catch {
1204
+ /* best-effort — see above */
1205
+ }
1206
+
1207
+ // How far the base has drifted since this candidate forked (unit c2), hoisted to the
1208
+ // whole flow: it drives BOTH the c2 pre-push warning (below) AND the c3 born-rebased
1209
+ // rebuild (after candidate_open). Stays 0 under --skip-freshness, so that flag opts
1210
+ // out of the rebuild too — consistent with opting out of the warning.
1211
+ let baseDrift = 0;
1212
+
1213
+ // Freshness preflight (unit u16) — BEFORE minting anything: is the checkout's
1214
+ // cached view of the base branch already behind the store? A candidate built
1215
+ // on a stale base is an instant, avoidable "not mergeable" the moment the
1216
+ // forge compares it against the real (already-advanced) base. --skip-freshness
1217
+ // opts out (e.g. offline/CI, or a deliberate re-run against a known-good tip).
1218
+ if (!args.skipFreshness) {
1219
+ let staleTip = null;
1220
+ try {
1221
+ staleTip = detectStaleBase(git, FRESHNESS_BASE_BRANCH);
1222
+ } catch {
1223
+ staleTip = null; // never block a submit on the preflight's OWN failure
1224
+ }
1225
+ if (staleTip) {
1226
+ const msg = `your checkout is behind the store — "${FRESHNESS_BASE_BRANCH}" has moved since your last sync`;
1227
+ console.error(
1228
+ 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`),
1229
+ );
1230
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1231
+ return 1;
1232
+ }
1233
+
1234
+ // Base-drift warning (unit c2) — a candidate rooted on a fork point the base has
1235
+ // since moved past is what settles mergeable=false at Accept time; catch it early,
1236
+ // right here, with a pure-LOCAL git read (no network — uses the base's
1237
+ // remote-tracking tip, which the stale-base preflight just above confirmed is
1238
+ // current). NON-BLOCKING: warn and submit anyway, so a work-in-progress preview
1239
+ // is never refused over base drift. Shares --skip-freshness with the preflight.
1240
+ try {
1241
+ baseDrift = baseCommitsBehind(git, FRESHNESS_BASE_BRANCH);
1242
+ } catch {
1243
+ baseDrift = 0; // never let the warning's OWN failure disturb the submit
1244
+ }
1245
+ if (baseDrift > 0) {
1246
+ console.error(`\n⚠ base is ${baseDrift} commit${baseDrift === 1 ? "" : "s"} behind — rebase before submit`);
1247
+ console.error(
1248
+ ` (\`${FRESHNESS_BASE_BRANCH}\` has advanced since your branch forked off it — \`tot sync\` to rebase, or --skip-freshness to silence)`,
1249
+ );
1250
+ }
1251
+ }
1252
+
1253
+
902
1254
  // 0. auto-commit the known content trees (unit u2) — on a dirty tree, commit your
903
1255
  // content edits BEFORE previewing so the preview reflects your working changes.
904
1256
  // Only content/, public/, theme.json, .tot/ (staged by explicit path, never
@@ -906,7 +1258,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
906
1258
  // --no-commit opts out (preview whatever's already committed).
907
1259
  let auto;
908
1260
  try {
909
- auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit });
1261
+ auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit, workspace });
910
1262
  } catch (e) {
911
1263
  emitGitOp("commit", false, { command: verb, errorClass: "git_commit_failed" });
912
1264
  const msg = `auto-commit failed: ${String(e?.stderr || e?.message || e)}`;
@@ -915,6 +1267,20 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
915
1267
  return 1;
916
1268
  }
917
1269
  if (auto.committed) emitGitOp("commit", true, { command: verb });
1270
+ if (auto.inProgress) {
1271
+ const abortCmd = abortCommandFor(auto.inProgress);
1272
+ const msg = `a ${auto.inProgress} is still in progress here`;
1273
+ console.error(
1274
+ fail(
1275
+ msg,
1276
+ `finish it (resolve + continue) or back out (\`${abortCmd}\`), then re-run \`tot ${verb}\` — `
1277
+ + "auto-committing over an unresolved rebase/merge/cherry-pick would fold your half-resolved "
1278
+ + "tree into a plain content commit and can lose whatever edit was still unresolved",
1279
+ ),
1280
+ );
1281
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1282
+ return 1;
1283
+ }
918
1284
  if (auto.refused) {
919
1285
  console.error(
920
1286
  fail(
@@ -937,6 +1303,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
937
1303
  // 1. validate locally — refuse on errors.
938
1304
  if (!args.skipValidate) {
939
1305
  const { ok, findings } = validateTenant(workspace, { tenantId: tenant, scope: ctx.config?.scope });
1306
+ // Advisory but LOUD: git conflict markers must never slip past as "validated"
1307
+ // (the half-resolved-rebase incident). Surfaced on the ok path too — warnings
1308
+ // are otherwise swallowed here — but they never block the submit.
1309
+ const conflicts = findings.filter((f) => f.rule === "git-conflict-markers");
1310
+ if (conflicts.length) {
1311
+ console.error(`\n⚠ git conflict markers in submitted content (${conflicts.length} file(s)) — an unfinished merge/rebase?`);
1312
+ for (const f of conflicts) console.error(` ⚠ ${f.file} — ${f.message}`);
1313
+ console.error(" The preview will still build, but it will serve the broken markers. Resolve before shipping.\n");
1314
+ }
940
1315
  if (!ok) {
941
1316
  const errs = findings.filter((f) => f.level === ERROR);
942
1317
  console.error(
@@ -1008,7 +1383,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1008
1383
  // needed — so they're available even on the no-session fallback path below.
1009
1384
  const branch = currentBranch(gitSafe);
1010
1385
  const statePath = defaultCandidateStatePath(env);
1011
- const active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
1386
+ let active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
1012
1387
 
1013
1388
  let session;
1014
1389
  try {
@@ -1020,7 +1395,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1020
1395
  // still lands if that embedded token is live. actorKeyFor(null) degrades to the
1021
1396
  // generic "developer" key — still isolated PER BRANCH (never the shared ref),
1022
1397
  // just not per-developer until sign-in succeeds.
1023
- const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, isNew: args.new });
1398
+ const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, forkCandidate: args.forkCandidate });
1024
1399
  const ref = resolvePushRef({ ref: args.ref, changeId });
1025
1400
  const { changeSummary } = buildSummaryAndPatch(ref);
1026
1401
  console.error(`~ pushing ${short} → ${ref} (origin)`);
@@ -1059,12 +1434,36 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1059
1434
  return 0;
1060
1435
  }
1061
1436
 
1437
+ // u17 — before REUSING a remembered active pointer, confirm its PR is still open.
1438
+ // If it merged/closed we DROP it (and forget it on disk) so chooseChangeId falls
1439
+ // back to the stable id, rather than force-pushing onto a now-dead candidate branch
1440
+ // and opening a NEW PR that inherits a guaranteed conflict (the live incident this
1441
+ // guards against). Skipped under --fork-candidate (chooseChangeId ignores `active`
1442
+ // there anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
1443
+ // Needs the tenant scope bound for candidate_status to resolve — idempotent with
1444
+ // the later client_switch / the fresh-mint checkoutTenant.
1445
+ if (active && repo && !args.forkCandidate) {
1446
+ try {
1447
+ await client.callTool("client_switch", { tenant });
1448
+ } catch { /* scope bind is best-effort; candidateStateFor tolerates a miss */ }
1449
+ const resolved = await resolveActivePointer(client, { repo, active });
1450
+ if (resolved.dropped) {
1451
+ console.error(
1452
+ `~ remembered candidate ${resolved.dropped.changeId} is ${resolved.dropped.state} — dropping it and submitting fresh (a ${resolved.dropped.state} PR can't be reused).`,
1453
+ );
1454
+ try {
1455
+ clearActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch });
1456
+ } catch { /* best-effort local cleanup — a miss just re-checks next run */ }
1457
+ }
1458
+ active = resolved.active;
1459
+ }
1460
+
1062
1461
  // Which candidate (and therefore which isolated ref, b03) this submit targets —
1063
1462
  // decided now, with a real session, so the SAME id backs both the raw git push
1064
1463
  // (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 });
1464
+ // different branches. See chooseChangeId's doc for the --fork-candidate /
1465
+ // active-pointer rules.
1466
+ let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, forkCandidate: args.forkCandidate });
1068
1467
  const ref = resolvePushRef({ ref: args.ref, changeId });
1069
1468
  const { changeSummary, patchEntries } = buildSummaryAndPatch(ref);
1070
1469
 
@@ -1132,6 +1531,61 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1132
1531
  candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
1133
1532
  }
1134
1533
 
1534
+ // c3 — BORN-REBASED at submit (shift-left prevention #2). The candidate is open;
1535
+ // if the base has drifted (the SAME signal c2 warned on, pre-push) rebuild it from
1536
+ // the CURRENT base tip via candidate_refresh BEFORE finalizing, so it enters the
1537
+ // queue already fresh instead of settling not-mergeable at Accept time.
1538
+ // • clean rebuild → report it, hand back the fresh candidate's shareable URL,
1539
+ // and finish (the rebuilt PR has a NEW head; polling the old
1540
+ // local `commit` would read as never-dispatched, so we skip
1541
+ // the reconcile poll and say the fresh preview is building);
1542
+ // • genuine overlap (merge_failed) → surface the resolve card right here, then
1543
+ // fall through — candidate_refresh made NO changes on a real
1544
+ // conflict, so the candidate's head still matches `commit`
1545
+ // and the normal reconcile poll below is still valid;
1546
+ // • anything else (owner-gated denial, older MCP, error) → submit as-is.
1547
+ // Best-effort throughout: candidate_refresh is app-owner gated, so an ordinary
1548
+ // invited developer's session may be denied — that degrades to submitting as-is,
1549
+ // never blocking the push that already landed. Gated on --skip-freshness via
1550
+ // baseDrift (0 when skipped).
1551
+ if (repo && baseDrift > 0 && candidate && !isTerminalCandidateState(candidate.state) && changeId) {
1552
+ const rebased = await runBornRebased(client, { repo, changeId, strategy: bornRebasedStrategy });
1553
+ if (rebased.ok) {
1554
+ for (const line of formatBornRebasedSuccess({ behind: baseDrift, strategy: rebased.strategy || bornRebasedStrategy, refreshedFiles: rebased.refreshedFiles })) {
1555
+ if (!args.json) console.log(line);
1556
+ }
1557
+ const freshPr = typeof rebased.prNumber === "number" ? rebased.prNumber : candidate.prNumber;
1558
+ const freshPrUrl = typeof freshPr === "number"
1559
+ ? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, freshPr)
1560
+ : null;
1561
+ if (freshPrUrl && !args.json) {
1562
+ console.log(`\n ▸ Your fresh preview will appear at:\n ${freshPrUrl}\n (building on the current store — this link goes live once reconcile completes)`);
1563
+ }
1564
+ // The rebuild keeps the STABLE changeId, so the persisted active pointer stays
1565
+ // valid — record it (best-effort) exactly as the normal open path does below.
1566
+ if (persist && repo) {
1567
+ try {
1568
+ writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
1569
+ } catch { /* best-effort local hint */ }
1570
+ }
1571
+ emitJson(args, buildJsonResult({
1572
+ ok: true, ref, commit, changeId,
1573
+ candidate: { ...candidate, prNumber: freshPr ?? candidate.prNumber },
1574
+ previewPrUrl: freshPrUrl,
1575
+ note: `born-rebased on the current base (${rebased.strategy || bornRebasedStrategy})`,
1576
+ }));
1577
+ return 0;
1578
+ }
1579
+ if (rebased.status === "merge_failed") {
1580
+ for (const line of formatBornRebasedConflict({ unresolved: rebased.unresolved }, verb)) {
1581
+ if (!args.json) console.log(line);
1582
+ }
1583
+ // fall through to the normal poll — the candidate is unchanged.
1584
+ } else if (rebased.message && !args.json) {
1585
+ console.log(` ~ couldn't auto-rebase on the current base (${rebased.message}) — submitting as-is.`);
1586
+ }
1587
+ }
1588
+
1135
1589
  // Immediate shareable URL (Vercel-style: "the URL exists before the build
1136
1590
  // does"). A non-terminal candidate with a real PR number means a preview
1137
1591
  // WILL be built at a deterministic route — so hand the developer that link
@@ -1372,7 +1826,7 @@ export function shareablePrUrl(base, tenant, prNumber) {
1372
1826
  * @returns {string}
1373
1827
  */
1374
1828
  export function describeReadbackError(e) {
1375
- const msg = String(e?.message || e || "");
1829
+ const msg = String(/** @type {any} */ (e)?.message || e || "");
1376
1830
  const jsonStart = msg.indexOf("{");
1377
1831
  if (jsonStart >= 0 && msg.includes("self_repair")) {
1378
1832
  try {
@@ -1428,7 +1882,7 @@ export function formatShareableUrlBlock(s, tenant) {
1428
1882
  * back / re-submit" lie for the dead-end case: re-submitting cannot help, so we say
1429
1883
  * what actually happened and what to do, and never recommend another submit. Pure —
1430
1884
  * unit-tested. `verb` brands the copy with whatever the developer typed.
1431
- * @param {{ commit?: string|null, ref?: string|null }} ctx
1885
+ * @param {{ commit?: string|null, ref?: string|null, noChanges?: boolean }} ctx
1432
1886
  * @param {string} tenant @param {string} [verb]
1433
1887
  * @returns {string[]}
1434
1888
  */
@@ -1485,6 +1939,10 @@ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
1485
1939
  * --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
1486
1940
  * feed the honest never-dispatched block.
1487
1941
  */
1942
+ /**
1943
+ * @param {any} s @param {string} tenant
1944
+ * @param {{ open?: boolean, quiet?: boolean, commit?: string|null, ref?: string|null, verb?: string, noChanges?: boolean }} [opts]
1945
+ */
1488
1946
  function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview", noChanges = false } = {}) {
1489
1947
  if (!s || s.status === "unknown") {
1490
1948
  if (!quiet) {