@tokenoftrust/cli 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +5 -0
  2. package/bin/tot.mjs +58 -79
  3. package/package.json +6 -1
  4. package/src/activity.mjs +15 -14
  5. package/src/app-scaffold.mjs +4 -4
  6. package/src/auth.mjs +13 -5
  7. package/src/candidate-state.mjs +3 -3
  8. package/src/commands/accept.mjs +65 -38
  9. package/src/commands/app/dev.mjs +8 -4
  10. package/src/commands/app/index.mjs +3 -3
  11. package/src/commands/app/scaffold.mjs +1 -1
  12. package/src/commands/branches.mjs +4 -3
  13. package/src/commands/cleanup.mjs +7 -11
  14. package/src/commands/clone.mjs +23 -20
  15. package/src/commands/dev.mjs +42 -24
  16. package/src/commands/doctor.mjs +4 -4
  17. package/src/commands/git-credential.mjs +2 -2
  18. package/src/commands/go-live.mjs +9 -5
  19. package/src/commands/grants.mjs +7 -5
  20. package/src/commands/hotfix.mjs +1 -1
  21. package/src/commands/ideas.mjs +2 -2
  22. package/src/commands/link.mjs +2 -2
  23. package/src/commands/login.mjs +5 -6
  24. package/src/commands/pr.mjs +33 -19
  25. package/src/commands/preview-build.mjs +6 -6
  26. package/src/commands/preview-doctor.mjs +225 -0
  27. package/src/commands/preview-retry-evidence.mjs +156 -0
  28. package/src/commands/preview.mjs +19 -3
  29. package/src/commands/revert.mjs +5 -5
  30. package/src/commands/rollback.mjs +18 -16
  31. package/src/commands/ship.mjs +29 -12
  32. package/src/commands/start.mjs +61 -51
  33. package/src/commands/submit.mjs +360 -50
  34. package/src/commands/sync.mjs +2 -2
  35. package/src/commands/validate.mjs +4 -3
  36. package/src/commands/whoami.mjs +1 -1
  37. package/src/dev-heartbeat.mjs +3 -2
  38. package/src/dev-logs.mjs +2 -2
  39. package/src/errors.mjs +11 -4
  40. package/src/git-credential.mjs +94 -21
  41. package/src/last-tenant.mjs +1 -1
  42. package/src/mcp.mjs +6 -1
  43. package/src/merge-doctor-report.mjs +208 -0
  44. package/src/oauth.mjs +18 -14
  45. package/src/obstacle-beacon.cjs +2 -2
  46. package/src/obstacle.mjs +1 -1
  47. package/src/plan.mjs +16 -21
  48. package/src/sample.mjs +4 -4
  49. package/src/validate.mjs +135 -15
  50. package/src/vendor/private-apps-devkit.mjs +3 -3
  51. package/src/viewer-session.mjs +118 -0
  52. package/template/private-app/README.md +12 -6
  53. package/src/commands/retire.mjs +0 -203
@@ -14,9 +14,10 @@
14
14
  *
15
15
  * This is submit-for-PREVIEW, not ship-to-live (`change_accept`/`candidate_accept` /
16
16
  * a future `tot ship` is the separate ship gate — this command is submit-only, never
17
- * accept/reject). Step 2b is best-effort: `candidate_open` failing (older MCP,
18
- * version-control not configured, preview-access capability) is reported and
19
- * swallowed it never blocks the preview push that already landed. Step 3 calls
17
+ * accept/reject). Step 2b remains best-effort for compatibility failures (older MCP,
18
+ * version-control not configured, preview-access capability), but an attribution
19
+ * refusal is REQUIRED and exits non-zero with identity-recovery guidance. The preview
20
+ * push may already have landed, but no unaudited PR is created. Step 3 calls
20
21
  * the MCP `preview_status` read-back: given the commit just pushed it returns
21
22
  * { status, reconcile, compliance, previewUrl } and we poll it while reconcile is
22
23
  * pending. If that tool isn't present (older MCP) the command still validates +
@@ -47,6 +48,10 @@ import { setTimeout as delay } from "node:timers/promises";
47
48
  import { createMcpClient } from "../mcp.mjs";
48
49
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
49
50
  import { checkoutTenant } from "./clone.mjs";
51
+ // Reuse the operator side's `candidate_refresh` result normaliser (unit c3 — the
52
+ // born-rebased submit rebuilds a candidate onto the current base with the SAME
53
+ // engine `tot accept --refresh` uses, so the two read its result identically).
54
+ import { normalizeRefreshResult } from "./accept.mjs";
50
55
  import { validateTenant, ERROR } from "../validate.mjs";
51
56
  import { openBrowser } from "../open.mjs";
52
57
  import { startProgress } from "../progress.mjs";
@@ -92,6 +97,8 @@ export function candidateRefFor(changeId) {
92
97
  * value). Fire-and-forget best-effort: a silent no-op without a hosted-bridge
93
98
  * credential, never awaited, never throws, never alters the command. `errorClass` is
94
99
  * a low-cardinality class (never a raw git stderr, which can carry a token/path).
100
+ * @param {string} op @param {boolean} ok
101
+ * @param {{ command?: string, durationMs?: number, errorClass?: string }} [opts]
95
102
  */
96
103
  function emitGitOp(op, ok, { command = "submit", durationMs, errorClass } = {}) {
97
104
  void emitActivity({
@@ -105,7 +112,8 @@ export function parseArgs(argv) {
105
112
  // `ref: null` — an explicit `--ref` always wins; otherwise the push target is
106
113
  // derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
107
114
  // never a fixed shared default.
108
- const a = { mcp: null, identity: null, ref: null, skipValidate: false, skipFreshness: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, summaryFile: null, json: false, new: false, help: false };
115
+ /** @type {{ mcp: string|null, identity: string|null, ref: string|null, skipValidate: boolean, skipFreshness: boolean, noWait: boolean, watch: boolean, noOpen: boolean, noCommit: boolean, message: string|null, summary: string|null, summaryFile: string|null, strategy: string|null, json: boolean, forkCandidate: boolean, help: boolean }} */
116
+ const a = { mcp: null, identity: null, ref: null, skipValidate: false, skipFreshness: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, summaryFile: null, strategy: null, json: false, forkCandidate: false, help: false };
109
117
  for (let i = 0; i < argv.length; i++) {
110
118
  const t = argv[i];
111
119
  if (t === "--mcp") a.mcp = argv[++i];
@@ -114,6 +122,7 @@ export function parseArgs(argv) {
114
122
  else if (t === "-m" || t === "--message") a.message = argv[++i];
115
123
  else if (t === "--summary") a.summary = argv[++i];
116
124
  else if (t === "--summary-file") a.summaryFile = argv[++i];
125
+ else if (t === "--strategy") a.strategy = argv[++i];
117
126
  else if (t === "--json") a.json = true;
118
127
  else if (t === "--skip-validate") a.skipValidate = true;
119
128
  else if (t === "--skip-freshness") a.skipFreshness = true;
@@ -121,7 +130,7 @@ export function parseArgs(argv) {
121
130
  else if (t === "--no-wait") a.noWait = true;
122
131
  else if (t === "--watch") a.watch = true;
123
132
  else if (t === "--no-open") a.noOpen = true;
124
- else if (t === "--new") a.new = true;
133
+ else if (t === "--fork-candidate") a.forkCandidate = true;
125
134
  else if (t === "--help" || t === "-h") a.help = true;
126
135
  }
127
136
  return a;
@@ -137,11 +146,20 @@ export function renderUsage(verb = "preview") {
137
146
  return `tot ${verb} — submit your store for preview
138
147
 
139
148
  tot ${verb} validate → push the preview ref → stream the result
140
- tot ${verb} --new open a NEW candidate PR instead of updating your open one
149
+ tot ${verb} --fork-candidate open a SECOND, independently-tracked candidate (a
150
+ parallel dev path). Rarely needed — a fresh git
151
+ branch already gets its own candidate automatically
152
+ (git checkout is the PR switcher); use this only to
153
+ run two candidates from ONE branch.
141
154
  tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
142
155
  tot ${verb} --skip-validate push without the local lint (not recommended)
143
- tot ${verb} --skip-freshness skip the stale-base check (not recommended may build a
144
- candidate rooted in an already-superseded base)
156
+ tot ${verb} --skip-freshness skip the stale-base check AND the born-rebased rebuild
157
+ (not recommended — may build a candidate rooted in an
158
+ already-superseded base)
159
+ tot ${verb} --strategy <s> how the born-rebased rebuild resolves a file changed on
160
+ BOTH sides when the base has moved: "merge" (real 3-way
161
+ merge, surfaces a resolve card on a genuine overlap — the
162
+ default), "ours" (keep yours), "theirs" (keep the store's)
145
163
  tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
146
164
  tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
147
165
  tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
@@ -159,13 +177,14 @@ export function renderUsage(verb = "preview") {
159
177
  tot ${verb} --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
160
178
 
161
179
  By default a re-run UPDATES your open candidate PR (like pushing more commits
162
- to a GitHub PR), rather than opening a new one each time. Use --new to fork a
163
- fresh candidate PR; the next plain \`tot ${verb}\` then updates THAT one. Manage your
164
- open candidates with \`tot pr\` (list / view / close). If your candidate was already
180
+ to a GitHub PR), rather than opening a new one each time. Manage your open
181
+ candidates with \`tot pr\` (list / view / close). If your candidate was already
165
182
  merged or closed, a re-run automatically opens a fresh one.
166
183
 
167
- Working on one thing at a time? You don't need --new, a git branch, or any
168
- branch management at all just keep editing and re-running \`tot ${verb}\`.
184
+ Starting a separate change? \`git checkout -b <branch>\` a fresh branch gets its
185
+ own candidate automatically, and \`git checkout\` back and forth is how you switch
186
+ between them. --fork-candidate is an escape hatch for the rarer case of wanting a
187
+ SECOND candidate off the SAME branch; reach for a branch first.
169
188
 
170
189
  Once a preview reconciles cleanly, \`tot ship\` promotes it live.
171
190
 
@@ -347,7 +366,7 @@ export const FRESHNESS_BASE_BRANCH = "preview";
347
366
  * Detect a STALE local view of the base branch before minting a candidate —
348
367
  * the live-repeat incident this guards against: the checkout's `origin/preview`
349
368
  * tracking ref was stale (recorded before a just-merged PR moved it), so a
350
- * fresh `tot preview --new` built a candidate rooted in the OLD tip and got an
369
+ * fresh `tot preview --fork-candidate` built a candidate rooted in the OLD tip and got an
351
370
  * instant, entirely avoidable "not mergeable" the moment it was compared
352
371
  * against the real, already-advanced `preview`.
353
372
  *
@@ -383,6 +402,166 @@ export function detectStaleBase(git, branch) {
383
402
  return remoteSha;
384
403
  }
385
404
 
405
+ /**
406
+ * How many commits the base branch has advanced since this candidate forked off it
407
+ * (unit c2 — the shift-left base-drift warning). Conflicts in this loop are BASE
408
+ * DRIFT: a candidate branches off `<base>`, sits while OTHER candidates advance
409
+ * `<base>`, then settles mergeable=false at Accept time. This measures that drift
410
+ * cheaply and LOCALLY — the merge-base (fork point) of HEAD and the base's
411
+ * remote-tracking tip, then how many commits separate that fork point from the tip.
412
+ *
413
+ * PURE LOCAL git — reads `refs/remotes/origin/<branch>` (the last-fetched tip),
414
+ * never the network; it complements detectStaleBase (which confirms that tracking
415
+ * ref is itself current). Returns 0 — never a false warning — when there's nothing
416
+ * to compare against or the drift can't be positively determined: no base tracking
417
+ * ref yet, unrelated histories / no merge-base, HEAD already contains the tip, or
418
+ * any git failure. Non-blocking by contract: the caller warns on a positive count
419
+ * but always proceeds. Pure git I/O via the injected runner — unit-tested.
420
+ * @param {(cargs:string[])=>string} git
421
+ * @param {string} branch
422
+ * @returns {number}
423
+ */
424
+ export function baseCommitsBehind(git, branch) {
425
+ const baseTip = `refs/remotes/origin/${branch}`;
426
+ let tip = "";
427
+ try {
428
+ tip = git(["rev-parse", "-q", "--verify", baseTip]).trim();
429
+ } catch {
430
+ return 0; // no local tracking ref for the base — nothing to compare against
431
+ }
432
+ if (!tip) return 0;
433
+ let mergeBase = "";
434
+ try {
435
+ mergeBase = git(["merge-base", "HEAD", baseTip]).trim();
436
+ } catch {
437
+ return 0; // unrelated histories / no HEAD — nothing meaningful to count
438
+ }
439
+ if (!mergeBase || mergeBase === tip) return 0; // HEAD already contains the base tip
440
+ try {
441
+ const n = parseInt(git(["rev-list", "--count", `${mergeBase}..${baseTip}`]).trim(), 10);
442
+ return Number.isFinite(n) && n > 0 ? n : 0;
443
+ } catch {
444
+ return 0;
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Resolve the git ref to diff HEAD against for the candidate's file patch (and its
450
+ * summary). The candidate PR is `preview + your file changes`, and `candidate_open`
451
+ * SERVER-CUTS the candidate branch back to the current preview tip and re-applies the
452
+ * patch — so the patch MUST be the FULL delta of your branch vs its fork point off
453
+ * `preview`, never "what changed since my last candidate push". Diffing against your
454
+ * own candidate tracking ref is what silently produced an empty patch (and the wrong
455
+ * "no file changes" skip, so no PR opened) after a push that landed but failed to open
456
+ * its PR: that ref already equals HEAD, so the since-last-push delta is empty even
457
+ * though the change vs preview is 19 files. Prefer the merge-base (fork point) with
458
+ * `origin/<baseBranch>`; fall back to the candidate tracking ref, then HEAD~1, then ""
459
+ * (single-commit `git show`) when no base ref resolves. Pure git I/O via the injected
460
+ * runner — unit-tested.
461
+ * @param {(cargs:string[])=>string} git
462
+ * @param {string} ref the candidate ref (e.g. "candidate/local-abc")
463
+ * @param {string} [baseBranch] the preview base branch (default FRESHNESS_BASE_BRANCH)
464
+ * @returns {string}
465
+ */
466
+ export function resolvePatchBase(git, ref, baseBranch = FRESHNESS_BASE_BRANCH) {
467
+ const verify = (r) => {
468
+ try { return git(["rev-parse", "--verify", "--quiet", r]).trim(); } catch { return ""; }
469
+ };
470
+ const previewRef = `refs/remotes/origin/${baseBranch}`;
471
+ if (verify(previewRef)) {
472
+ try {
473
+ const forkPoint = git(["merge-base", "HEAD", previewRef]).trim();
474
+ if (forkPoint) return forkPoint;
475
+ } catch { /* unrelated histories — fall through */ }
476
+ }
477
+ const trackingRef = `refs/remotes/origin/${ref}`;
478
+ if (verify(trackingRef)) return trackingRef;
479
+ if (verify("HEAD~1")) return "HEAD~1";
480
+ return "";
481
+ }
482
+
483
+ // ─── born-rebased at submit (unit c3 — shift-left prevention #2) ─────────────────
484
+
485
+ /** The three strategies the born-rebased rebuild accepts, mirroring `tot accept
486
+ * --refresh` (accept.mjs) and the admin one-click resolver: `merge` (real 3-way,
487
+ * refuses on a genuine overlap), `ours` (keep yours), `theirs` (keep the store's).
488
+ * `merge` is the DEFAULT — auto-rebuild on pure drift, name the conflict on a real
489
+ * same-line overlap, never silently clobber a side. */
490
+ export const BORN_REBASED_STRATEGIES = ["ours", "theirs", "merge"];
491
+ export const DEFAULT_BORN_REBASED_STRATEGY = "merge";
492
+
493
+ /**
494
+ * Run `candidate_refresh` (the born-rebased rebuild, unit c3) over the ALREADY
495
+ * established MCP client — the candidate was just opened by candidate_open (step 2b)
496
+ * and the tenant scope is already bound, so this reuses that session rather than
497
+ * establishing its own (accept.mjs's runRefresh is the operator entry that does the
498
+ * sign-in; here the submit flow already holds the session). Rebuilds the candidate
499
+ * from the CURRENT base tip and re-applies its file changes under `strategy`. Returns
500
+ * the normalized result (status:"committed" is the only success). Best-effort: any
501
+ * throw — an owner-gated denial (candidate_refresh is app-owner gated), an older MCP
502
+ * without the tool, a transient failure — normalizes to a non-ok error result the
503
+ * caller submits-as-is on, NEVER blocking the push that already landed.
504
+ * @param {{callTool:Function}} client
505
+ * @param {{ repo: string, changeId: string, strategy: string }} opts
506
+ * @returns {Promise<ReturnType<typeof normalizeRefreshResult>>}
507
+ */
508
+ export async function runBornRebased(client, { repo, changeId, strategy }) {
509
+ try {
510
+ const raw = await client.callTool("candidate_refresh", { repo, changeId, strategy });
511
+ return normalizeRefreshResult(raw);
512
+ } catch (e) {
513
+ return { ...normalizeRefreshResult(null), status: "error", message: String(e?.message || e) };
514
+ }
515
+ }
516
+
517
+ /**
518
+ * The born-rebased SUCCESS lines (unit c3) — printed when the candidate rebuilt
519
+ * cleanly onto the current base tip, so it enters the queue already mergeable rather
520
+ * than settling not-mergeable at Accept time. `behind` is the base-drift count that
521
+ * triggered the rebuild. Pure — unit-tested.
522
+ * @param {{ behind?: number, strategy?: string, refreshedFiles?: string[] }} input
523
+ * @returns {string[]}
524
+ */
525
+ export function formatBornRebasedSuccess({ behind = 0, strategy = DEFAULT_BORN_REBASED_STRATEGY, refreshedFiles = [] } = {}) {
526
+ const drift = behind > 0 ? `${behind} commit${behind === 1 ? "" : "s"}` : "since you forked";
527
+ const lines = [
528
+ `\n ✓ rebuilt fresh on the current store (base had advanced ${drift}, strategy ${strategy}) — your candidate enters the queue already mergeable.`,
529
+ ];
530
+ if (refreshedFiles.length) lines.push(` reapplied: ${refreshedFiles.join(", ")}`);
531
+ return lines;
532
+ }
533
+
534
+ /**
535
+ * The born-rebased RESOLVE CARD (unit c3) — printed when the default `merge` rebuild
536
+ * hits a GENUINE same-line overlap with the base (not mere drift): the biggest single
537
+ * shift-left win is surfacing that conflict HERE, at submit, instead of letting it
538
+ * land as a stuck queue row someone discovers at Accept time. Names the diverged files
539
+ * and offers the two one-command resolutions (keep-mine / keep-store's) — `merge` is
540
+ * the default that just failed, so it isn't re-offered. Matches how the rest of this
541
+ * file reports next-steps (a `✗` headline + concrete `tot <verb> …` commands), never a
542
+ * raw git rebase instruction. Pure — unit-tested. `verb` brands the copy with whatever
543
+ * the developer typed.
544
+ * @param {{ unresolved?: string[] }} rr
545
+ * @param {string} [verb]
546
+ * @returns {string[]}
547
+ */
548
+ export function formatBornRebasedConflict({ unresolved = [] } = {}, verb = "preview") {
549
+ const lines = [
550
+ `\n ✗ your change conflicts with the current store on the same lines — it can't be auto-rebased.`,
551
+ ];
552
+ if (unresolved.length) {
553
+ lines.push(` These files changed on both sides since you forked and need your call:`);
554
+ for (const f of unresolved) lines.push(` - ${f}`);
555
+ }
556
+ lines.push(
557
+ ` Resolve it in one command — choose which side wins on those files:`,
558
+ ` tot ${verb} --strategy=ours keep YOURS on any clash`,
559
+ ` tot ${verb} --strategy=theirs keep the STORE's on any clash`,
560
+ ` Your push is in; the candidate is submitted but stays not-mergeable until you resolve it.`,
561
+ );
562
+ return lines;
563
+ }
564
+
386
565
  // ─── auto-commit the known content trees (unit u2) ───────────────────────────────
387
566
 
388
567
  /**
@@ -599,9 +778,10 @@ export function parseNameStatus(text) {
599
778
  * @returns {Array<{path: string, content?: string, contentEncoding?: "base64", delete?: true}>}
600
779
  */
601
780
  export function buildFilePatch(entries, readBlob) {
781
+ /** @type {Array<{ path: string, content?: string, contentEncoding?: "base64", delete?: true }>} */
602
782
  const patch = [];
603
783
  for (const e of entries) {
604
- if (e.status === "R") patch.push({ path: e.from, delete: true });
784
+ if (e.status === "R") patch.push({ path: /** @type {string} */ (e.from), delete: true });
605
785
  if (e.status === "D") {
606
786
  patch.push({ path: e.path, delete: true });
607
787
  continue;
@@ -693,7 +873,7 @@ export function tagFromRepoName(repoName, tenant) {
693
873
  *
694
874
  * @param {(cargs:string[])=>string} git throwing git runner (execFileSync-backed)
695
875
  * @param {() => Promise<string|null>} mintRemote mints a fresh authed gitRemote (null when unavailable)
696
- * @param {{ ref: string }} opts
876
+ * @param {{ ref?: string }} [opts]
697
877
  * @returns {Promise<{ out: string }>} resolves on a successful push; throws (git's error) otherwise
698
878
  */
699
879
  export async function pushPreviewRef(git, mintRemote, { ref } = {}) {
@@ -779,22 +959,23 @@ export function actorKeyFor(session) {
779
959
  * Which candidate this submit lands on (gh-pr-like) — decided UP FRONT, before any
780
960
  * network call, because it also determines the isolated git ref we push to
781
961
  * (resolvePushRef, below): a re-submit updates the SAME candidate/ref by default;
782
- * `--new` forks a fresh one.
783
- * --new → fork a FRESH candidate id;
784
- * otherwise → the remembered active candidate (from a prior --new / terminal
785
- * roll), else the STABLE per-dev-per-tenant(-per-branch) default.
962
+ * `--fork-candidate` forks a fresh one.
963
+ * forkCandidate → fork a FRESH candidate id;
964
+ * otherwise → the remembered active candidate (from a prior --fork-candidate /
965
+ * terminal roll), else the STABLE per-dev-per-tenant(-per-branch)
966
+ * default.
786
967
  * `persist` reports whether the choice diverges from the stable default, so the
787
968
  * caller knows whether to remember it as the new active pointer. `mint` is
788
969
  * injected (defaults to mintFreshChangeId) so this is pure/deterministic in tests.
789
970
  * Pure — unit-tested.
790
971
  * @param {{ tenant: string, actorKey: string, branch?: string|null, active?: string|null,
791
- * isNew?: boolean, mint?: (baseId: string) => string }} opts
972
+ * forkCandidate?: boolean, mint?: (baseId: string) => string }} opts
792
973
  * @returns {{ changeId: string, stableId: string, persist: boolean }}
793
974
  */
794
- export function chooseChangeId({ tenant, actorKey, branch = null, active = null, isNew = false, mint = mintFreshChangeId }) {
975
+ export function chooseChangeId({ tenant, actorKey, branch = null, active = null, forkCandidate = false, mint = mintFreshChangeId }) {
795
976
  const stableId = deriveChangeId(tenant, actorKey, branch);
796
- const changeId = isNew ? mint(stableId) : (active || stableId);
797
- const persist = isNew || (!!active && active !== stableId);
977
+ const changeId = forkCandidate ? mint(stableId) : (active || stableId);
978
+ const persist = forkCandidate || (!!active && active !== stableId);
798
979
  return { changeId, stableId, persist };
799
980
  }
800
981
 
@@ -847,7 +1028,7 @@ export async function candidateStateFor(client, { repo, changeId }) {
847
1028
  export async function resolveActivePointer(client, { repo, active }) {
848
1029
  if (!active || !repo) return { active };
849
1030
  const state = await candidateStateFor(client, { repo, changeId: active });
850
- if (isTerminalCandidateState(state)) return { active: null, dropped: { changeId: active, state } };
1031
+ if (isTerminalCandidateState(state)) return { active: null, dropped: { changeId: active, state: /** @type {string} */ (state) } };
851
1032
  return { active };
852
1033
  }
853
1034
 
@@ -866,6 +1047,18 @@ export function resolvePushRef({ ref, changeId }) {
866
1047
  return ref || candidateRefFor(changeId);
867
1048
  }
868
1049
 
1050
+ const REQUIRED_ATTRIBUTION_REFUSALS = new Set([
1051
+ "audit_identity_unavailable",
1052
+ "audit_unavailable",
1053
+ ]);
1054
+
1055
+ class CandidateAttributionError extends Error {
1056
+ constructor(message) {
1057
+ super(message);
1058
+ this.name = "CandidateAttributionError";
1059
+ }
1060
+ }
1061
+
869
1062
  /**
870
1063
  * Open/update the PR-backed candidate for this submit (g1b `candidate_open`,
871
1064
  * unit c1 — the local-dev-loop half of the "PR-Backed Hosted Review Loop"
@@ -874,8 +1067,8 @@ export function resolvePushRef({ ref, changeId }) {
874
1067
  * title/body as the PR title/description, and prints the resulting
875
1068
  * changeId/PR number/URL. Best-effort: any failure (no repo could be derived,
876
1069
  * older MCP, version control not configured, preview-access capability, …) is
877
- * reported and swallowed — it never blocks the preview push that already
878
- * landed or the reconcile/compliance read-back that follows.
1070
+ * reported and swallowed — except an audit/identity refusal, which is required
1071
+ * and propagates so the command exits non-zero with the MCP's recovery guidance.
879
1072
  * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
880
1073
  * @param {{ repo: string|null, changeId: string, changeSummary: {title:string, body:string[]},
881
1074
  * patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer,
@@ -903,9 +1096,15 @@ export async function submitCandidate(client, { repo, changeId, changeSummary, p
903
1096
  body: changeSummary.body.length ? changeSummary.body.join("\n").slice(0, 4000) : undefined,
904
1097
  patch,
905
1098
  });
1099
+ if (REQUIRED_ATTRIBUTION_REFUSALS.has(result?.status)) {
1100
+ throw new CandidateAttributionError(
1101
+ result?.message || "Candidate not created: verified actor attribution is required.",
1102
+ );
1103
+ }
906
1104
  reportCandidate(result, changeId, { quiet });
907
1105
  return result;
908
1106
  } catch (e) {
1107
+ if (e instanceof CandidateAttributionError) throw e;
909
1108
  if (!quiet) {
910
1109
  console.log(` ~ couldn't open/update the PR-backed candidate: ${String(e?.message || e)}`);
911
1110
  console.log(` (best-effort — your push is still in; this doesn't block reconcile.)`);
@@ -977,7 +1176,7 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
977
1176
  shipped: status?.shipped ?? null,
978
1177
  dispatched: status?.dispatched ?? null,
979
1178
  notDispatched: status?.notDispatched ?? false,
980
- forwardFailed: status?.forwardFailed ?? false,
1179
+ forwardFailed: /** @type {any} */ (status)?.forwardFailed ?? false,
981
1180
  delivery: status?.delivery ?? null,
982
1181
  previewPrUrl,
983
1182
  ...(error ? { error } : {}),
@@ -1015,6 +1214,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1015
1214
  emitJson(args, buildJsonResult({ ok: false, error: msg }));
1016
1215
  return 2;
1017
1216
  }
1217
+ // --strategy (c3 born-rebased rebuild) — validate whenever given; defaults to
1218
+ // "merge" (auto-rebuild on drift, surface a resolve card on a genuine overlap).
1219
+ if (args.strategy != null && !BORN_REBASED_STRATEGIES.includes(args.strategy)) {
1220
+ const msg = `unknown --strategy "${args.strategy}"`;
1221
+ console.error(fail(msg, `use one of: ${BORN_REBASED_STRATEGIES.join(", ")} (default "${DEFAULT_BORN_REBASED_STRATEGY}")`));
1222
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1223
+ return 2;
1224
+ }
1225
+ const bornRebasedStrategy = args.strategy || DEFAULT_BORN_REBASED_STRATEGY;
1018
1226
  if (args.summaryFile) {
1019
1227
  let raw;
1020
1228
  try {
@@ -1050,6 +1258,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1050
1258
  /* best-effort — see above */
1051
1259
  }
1052
1260
 
1261
+ // How far the base has drifted since this candidate forked (unit c2), hoisted to the
1262
+ // whole flow: it drives BOTH the c2 pre-push warning (below) AND the c3 born-rebased
1263
+ // rebuild (after candidate_open). Stays 0 under --skip-freshness, so that flag opts
1264
+ // out of the rebuild too — consistent with opting out of the warning.
1265
+ let baseDrift = 0;
1266
+
1053
1267
  // Freshness preflight (unit u16) — BEFORE minting anything: is the checkout's
1054
1268
  // cached view of the base branch already behind the store? A candidate built
1055
1269
  // on a stale base is an instant, avoidable "not mergeable" the moment the
@@ -1070,6 +1284,24 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1070
1284
  emitJson(args, buildJsonResult({ ok: false, error: msg }));
1071
1285
  return 1;
1072
1286
  }
1287
+
1288
+ // Base-drift warning (unit c2) — a candidate rooted on a fork point the base has
1289
+ // since moved past is what settles mergeable=false at Accept time; catch it early,
1290
+ // right here, with a pure-LOCAL git read (no network — uses the base's
1291
+ // remote-tracking tip, which the stale-base preflight just above confirmed is
1292
+ // current). NON-BLOCKING: warn and submit anyway, so a work-in-progress preview
1293
+ // is never refused over base drift. Shares --skip-freshness with the preflight.
1294
+ try {
1295
+ baseDrift = baseCommitsBehind(git, FRESHNESS_BASE_BRANCH);
1296
+ } catch {
1297
+ baseDrift = 0; // never let the warning's OWN failure disturb the submit
1298
+ }
1299
+ if (baseDrift > 0) {
1300
+ console.error(`\n⚠ base is ${baseDrift} commit${baseDrift === 1 ? "" : "s"} behind — rebase before submit`);
1301
+ console.error(
1302
+ ` (\`${FRESHNESS_BASE_BRANCH}\` has advanced since your branch forked off it — \`tot sync\` to rebase, or --skip-freshness to silence)`,
1303
+ );
1304
+ }
1073
1305
  }
1074
1306
 
1075
1307
 
@@ -1177,12 +1409,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1177
1409
  };
1178
1410
  function buildSummaryAndPatch(ref) {
1179
1411
  const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
1180
- const trackingRef = `refs/remotes/origin/${ref}`;
1181
- const base = gitSafe(["rev-parse", "--verify", "--quiet", trackingRef]).trim()
1182
- ? trackingRef
1183
- : gitSafe(["rev-parse", "--verify", "--quiet", "HEAD~1"]).trim()
1184
- ? "HEAD~1"
1185
- : "";
1412
+ // Diff against the fork point off `preview` — the FULL branch delta — so the patch
1413
+ // is complete for candidate_open's server-cut (which resets the branch to preview
1414
+ // and re-applies this patch). NOT the candidate's own tracking ref: after a push
1415
+ // that landed but failed to open its PR, that ref equals HEAD → empty patch →
1416
+ // wrong "no file changes" → no PR. See resolvePatchBase.
1417
+ const base = resolvePatchBase(gitSafe, ref);
1186
1418
  const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
1187
1419
  const patchEntries = parseNameStatus(gitSafe(statusCmd));
1188
1420
  const files = patchEntries.map((e) => e.path);
@@ -1217,7 +1449,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1217
1449
  // still lands if that embedded token is live. actorKeyFor(null) degrades to the
1218
1450
  // generic "developer" key — still isolated PER BRANCH (never the shared ref),
1219
1451
  // just not per-developer until sign-in succeeds.
1220
- const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, isNew: args.new });
1452
+ const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, forkCandidate: args.forkCandidate });
1221
1453
  const ref = resolvePushRef({ ref: args.ref, changeId });
1222
1454
  const { changeSummary } = buildSummaryAndPatch(ref);
1223
1455
  console.error(`~ pushing ${short} → ${ref} (origin)`);
@@ -1260,11 +1492,11 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1260
1492
  // If it merged/closed we DROP it (and forget it on disk) so chooseChangeId falls
1261
1493
  // back to the stable id, rather than force-pushing onto a now-dead candidate branch
1262
1494
  // and opening a NEW PR that inherits a guaranteed conflict (the live incident this
1263
- // guards against). Skipped under --new (chooseChangeId ignores `active` there
1264
- // anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
1495
+ // guards against). Skipped under --fork-candidate (chooseChangeId ignores `active`
1496
+ // there anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
1265
1497
  // Needs the tenant scope bound for candidate_status to resolve — idempotent with
1266
1498
  // the later client_switch / the fresh-mint checkoutTenant.
1267
- if (active && repo && !args.new) {
1499
+ if (active && repo && !args.forkCandidate) {
1268
1500
  try {
1269
1501
  await client.callTool("client_switch", { tenant });
1270
1502
  } catch { /* scope bind is best-effort; candidateStateFor tolerates a miss */ }
@@ -1283,9 +1515,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1283
1515
  // Which candidate (and therefore which isolated ref, b03) this submit targets —
1284
1516
  // decided now, with a real session, so the SAME id backs both the raw git push
1285
1517
  // (right below) and the PR-backed candidate (step 2b): the two never point at
1286
- // different branches. See chooseChangeId's doc for the --new / active-pointer
1287
- // rules.
1288
- let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, isNew: args.new });
1518
+ // different branches. See chooseChangeId's doc for the --fork-candidate /
1519
+ // active-pointer rules.
1520
+ let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, forkCandidate: args.forkCandidate });
1289
1521
  const ref = resolvePushRef({ ref: args.ref, changeId });
1290
1522
  const { changeSummary, patchEntries } = buildSummaryAndPatch(ref);
1291
1523
 
@@ -1334,9 +1566,10 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1334
1566
  // (idempotent — checkoutTenant already switched when the fresh mint succeeded).
1335
1567
  await client.callTool("client_switch", { tenant });
1336
1568
 
1337
- // 2b. PR-backed candidate (g1b candidate_open, unit c1) best-effort: a
1338
- // failure here (older MCP, VC not configured, preview-access capability) is
1339
- // reported and swallowed, never blocking the preview push that already landed.
1569
+ // 2b. PR-backed candidate (g1b candidate_open, unit c1). Compatibility and
1570
+ // capability failures remain best-effort, but the MCP's audit/identity refusals
1571
+ // propagate and make this command fail: an unaudited PR is never an acceptable
1572
+ // successful submit.
1340
1573
  // `changeId`/`stableId`/`persist` were already decided above (they picked the
1341
1574
  // push ref too); if the chosen candidate turns out to be merged/closed, roll to
1342
1575
  // a fresh one so a re-submit is never wedged on a dead PR. (`repo` was derived
@@ -1353,6 +1586,61 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1353
1586
  candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
1354
1587
  }
1355
1588
 
1589
+ // c3 — BORN-REBASED at submit (shift-left prevention #2). The candidate is open;
1590
+ // if the base has drifted (the SAME signal c2 warned on, pre-push) rebuild it from
1591
+ // the CURRENT base tip via candidate_refresh BEFORE finalizing, so it enters the
1592
+ // queue already fresh instead of settling not-mergeable at Accept time.
1593
+ // • clean rebuild → report it, hand back the fresh candidate's shareable URL,
1594
+ // and finish (the rebuilt PR has a NEW head; polling the old
1595
+ // local `commit` would read as never-dispatched, so we skip
1596
+ // the reconcile poll and say the fresh preview is building);
1597
+ // • genuine overlap (merge_failed) → surface the resolve card right here, then
1598
+ // fall through — candidate_refresh made NO changes on a real
1599
+ // conflict, so the candidate's head still matches `commit`
1600
+ // and the normal reconcile poll below is still valid;
1601
+ // • anything else (owner-gated denial, older MCP, error) → submit as-is.
1602
+ // Best-effort throughout: candidate_refresh is app-owner gated, so an ordinary
1603
+ // invited developer's session may be denied — that degrades to submitting as-is,
1604
+ // never blocking the push that already landed. Gated on --skip-freshness via
1605
+ // baseDrift (0 when skipped).
1606
+ if (repo && baseDrift > 0 && candidate && !isTerminalCandidateState(candidate.state) && changeId) {
1607
+ const rebased = await runBornRebased(client, { repo, changeId, strategy: bornRebasedStrategy });
1608
+ if (rebased.ok) {
1609
+ for (const line of formatBornRebasedSuccess({ behind: baseDrift, strategy: rebased.strategy || bornRebasedStrategy, refreshedFiles: rebased.refreshedFiles })) {
1610
+ if (!args.json) console.log(line);
1611
+ }
1612
+ const freshPr = typeof rebased.prNumber === "number" ? rebased.prNumber : candidate.prNumber;
1613
+ const freshPrUrl = typeof freshPr === "number"
1614
+ ? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, freshPr)
1615
+ : null;
1616
+ if (freshPrUrl && !args.json) {
1617
+ console.log(`\n ▸ Your fresh preview will appear at:\n ${freshPrUrl}\n (building on the current store — this link goes live once reconcile completes)`);
1618
+ }
1619
+ // The rebuild keeps the STABLE changeId, so the persisted active pointer stays
1620
+ // valid — record it (best-effort) exactly as the normal open path does below.
1621
+ if (persist && repo) {
1622
+ try {
1623
+ writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
1624
+ } catch { /* best-effort local hint */ }
1625
+ }
1626
+ emitJson(args, buildJsonResult({
1627
+ ok: true, ref, commit, changeId,
1628
+ candidate: { ...candidate, prNumber: freshPr ?? candidate.prNumber },
1629
+ previewPrUrl: freshPrUrl,
1630
+ note: `born-rebased on the current base (${rebased.strategy || bornRebasedStrategy})`,
1631
+ }));
1632
+ return 0;
1633
+ }
1634
+ if (rebased.status === "merge_failed") {
1635
+ for (const line of formatBornRebasedConflict({ unresolved: rebased.unresolved }, verb)) {
1636
+ if (!args.json) console.log(line);
1637
+ }
1638
+ // fall through to the normal poll — the candidate is unchanged.
1639
+ } else if (rebased.message && !args.json) {
1640
+ console.log(` ~ couldn't auto-rebase on the current base (${rebased.message}) — submitting as-is.`);
1641
+ }
1642
+ }
1643
+
1356
1644
  // Immediate shareable URL (Vercel-style: "the URL exists before the build
1357
1645
  // does"). A non-terminal candidate with a real PR number means a preview
1358
1646
  // WILL be built at a deterministic route — so hand the developer that link
@@ -1422,6 +1710,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1422
1710
  return status?.status === "failed" ? 1 : 0;
1423
1711
  } catch (e) {
1424
1712
  progress?.stop();
1713
+ if (e instanceof CandidateAttributionError) {
1714
+ const note = `${e.message} Your preview push is in, but no review PR was created.`;
1715
+ if (!args.json) console.error(fail(note, "follow the identity guidance above, then re-run `tot submit`"));
1716
+ emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
1717
+ return 1;
1718
+ }
1425
1719
  const note = e instanceof AuthUnavailableError
1426
1720
  ? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
1427
1721
  : `reconcile is running — the result read-back isn't available yet: ${describeReadbackError(e)}`;
@@ -1489,16 +1783,28 @@ export function normalizePreviewStatus(r) {
1489
1783
  * still absent, we stop and return the honest state tagged `notDispatched: true`. If
1490
1784
  * a delivery IS seen we keep polling as before (dispatched, just slow), and a plain
1491
1785
  * timeout while still pending is tagged `notDispatched` only when a delivery was never
1492
- * observed. Injectable delay/attempts/waitMs/grace + a `now` clock for tests.
1786
+ * observed. Injectable delay/attempts/waitMs/grace + a `now` clock and `sleep`
1787
+ * fn for tests (so the fallback-sleep cadence can be asserted deterministically
1788
+ * off the injected clock instead of real wall-clock elapsed time).
1493
1789
  * @param {{callTool:Function}} client
1494
1790
  * @param {string} commit
1495
1791
  * @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean,
1496
- * notDispatchedGraceMs?: number, now?: () => number, onTick?: (s:object,i:number)=>void }} [opts]
1792
+ * notDispatchedGraceMs?: number, now?: () => number, sleep?: (ms:number) => Promise<void>,
1793
+ * onTick?: (s:object,i:number)=>void }} [opts]
1497
1794
  */
1498
1795
  export async function pollPreviewStatus(
1499
1796
  client,
1500
1797
  commit,
1501
- { attempts = 8, delayMs = 2500, waitMs = delayMs, untilShipped = false, notDispatchedGraceMs = 15_000, now = Date.now, onTick } = {},
1798
+ {
1799
+ attempts = 8,
1800
+ delayMs = 2500,
1801
+ waitMs = delayMs,
1802
+ untilShipped = false,
1803
+ notDispatchedGraceMs = 15_000,
1804
+ now = Date.now,
1805
+ sleep = delay,
1806
+ onTick,
1807
+ } = {},
1502
1808
  ) {
1503
1809
  let last = null;
1504
1810
  let everDispatched = false;
@@ -1533,7 +1839,7 @@ export async function pollPreviewStatus(
1533
1839
  if (last.status !== "pending" && !stillWatchingForShip) return last;
1534
1840
  if (i < attempts - 1) {
1535
1841
  const remaining = delayMs - (now() - startedAt);
1536
- if (remaining > 0) await delay(remaining);
1842
+ if (remaining > 0) await sleep(remaining);
1537
1843
  }
1538
1844
  }
1539
1845
  // Budget exhausted. A still-pending result that never saw a delivery is a
@@ -1593,7 +1899,7 @@ export function shareablePrUrl(base, tenant, prNumber) {
1593
1899
  * @returns {string}
1594
1900
  */
1595
1901
  export function describeReadbackError(e) {
1596
- const msg = String(e?.message || e || "");
1902
+ const msg = String(/** @type {any} */ (e)?.message || e || "");
1597
1903
  const jsonStart = msg.indexOf("{");
1598
1904
  if (jsonStart >= 0 && msg.includes("self_repair")) {
1599
1905
  try {
@@ -1649,7 +1955,7 @@ export function formatShareableUrlBlock(s, tenant) {
1649
1955
  * back / re-submit" lie for the dead-end case: re-submitting cannot help, so we say
1650
1956
  * what actually happened and what to do, and never recommend another submit. Pure —
1651
1957
  * unit-tested. `verb` brands the copy with whatever the developer typed.
1652
- * @param {{ commit?: string|null, ref?: string|null }} ctx
1958
+ * @param {{ commit?: string|null, ref?: string|null, noChanges?: boolean }} ctx
1653
1959
  * @param {string} tenant @param {string} [verb]
1654
1960
  * @returns {string[]}
1655
1961
  */
@@ -1706,6 +2012,10 @@ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
1706
2012
  * --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
1707
2013
  * feed the honest never-dispatched block.
1708
2014
  */
2015
+ /**
2016
+ * @param {any} s @param {string} tenant
2017
+ * @param {{ open?: boolean, quiet?: boolean, commit?: string|null, ref?: string|null, verb?: string, noChanges?: boolean }} [opts]
2018
+ */
1709
2019
  function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview", noChanges = false } = {}) {
1710
2020
  if (!s || s.status === "unknown") {
1711
2021
  if (!quiet) {