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

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,7 +40,8 @@
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";
@@ -55,6 +56,7 @@ import {
55
56
  defaultCandidateStatePath,
56
57
  readActiveChangeId,
57
58
  writeActiveChangeId,
59
+ clearActiveChangeId,
58
60
  mintFreshChangeId,
59
61
  isTerminalCandidateState,
60
62
  isDefaultBranch,
@@ -90,6 +92,8 @@ export function candidateRefFor(changeId) {
90
92
  * value). Fire-and-forget best-effort: a silent no-op without a hosted-bridge
91
93
  * credential, never awaited, never throws, never alters the command. `errorClass` is
92
94
  * a low-cardinality class (never a raw git stderr, which can carry a token/path).
95
+ * @param {string} op @param {boolean} ok
96
+ * @param {{ command?: string, durationMs?: number, errorClass?: string }} [opts]
93
97
  */
94
98
  function emitGitOp(op, ok, { command = "submit", durationMs, errorClass } = {}) {
95
99
  void emitActivity({
@@ -103,7 +107,8 @@ export function parseArgs(argv) {
103
107
  // `ref: null` — an explicit `--ref` always wins; otherwise the push target is
104
108
  // derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
105
109
  // 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 };
110
+ /** @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, json: boolean, new: boolean, help: boolean }} */
111
+ 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 };
107
112
  for (let i = 0; i < argv.length; i++) {
108
113
  const t = argv[i];
109
114
  if (t === "--mcp") a.mcp = argv[++i];
@@ -114,6 +119,7 @@ export function parseArgs(argv) {
114
119
  else if (t === "--summary-file") a.summaryFile = argv[++i];
115
120
  else if (t === "--json") a.json = true;
116
121
  else if (t === "--skip-validate") a.skipValidate = true;
122
+ else if (t === "--skip-freshness") a.skipFreshness = true;
117
123
  else if (t === "--no-commit") a.noCommit = true;
118
124
  else if (t === "--no-wait") a.noWait = true;
119
125
  else if (t === "--watch") a.watch = true;
@@ -137,6 +143,8 @@ export function renderUsage(verb = "preview") {
137
143
  tot ${verb} --new open a NEW candidate PR instead of updating your open one
138
144
  tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
139
145
  tot ${verb} --skip-validate push without the local lint (not recommended)
146
+ tot ${verb} --skip-freshness skip the stale-base check (not recommended — may build a
147
+ candidate rooted in an already-superseded base)
140
148
  tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
141
149
  tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
142
150
  tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
@@ -159,6 +167,9 @@ export function renderUsage(verb = "preview") {
159
167
  open candidates with \`tot pr\` (list / view / close). If your candidate was already
160
168
  merged or closed, a re-run automatically opens a fresh one.
161
169
 
170
+ Working on one thing at a time? You don't need --new, a git branch, or any
171
+ branch management at all — just keep editing and re-running \`tot ${verb}\`.
172
+
162
173
  Once a preview reconciles cleanly, \`tot ship\` promotes it live.
163
174
 
164
175
  If you omit -m, a summary is generated from git (commit subject + the diff vs
@@ -326,6 +337,55 @@ export function readSummaryFileContent(pathOrDash) {
326
337
  return readFileSync(pathOrDash, "utf8");
327
338
  }
328
339
 
340
+ // ─── stale-base freshness preflight (unit u16) ──────────────────────────────────
341
+
342
+ /** The candidate base branch every submit targets (mirrors tot-mcp's own
343
+ * `DEFAULT_CANDIDATE_BASE` and sync.mjs's `DEFAULT_SYNC_BRANCH` — the SAME
344
+ * protected branch by three different names in three different modules; kept
345
+ * a local constant here, not imported, since this file already resolves its
346
+ * own defaults independently of sync.mjs and candidate_open's server default). */
347
+ export const FRESHNESS_BASE_BRANCH = "preview";
348
+
349
+ /**
350
+ * Detect a STALE local view of the base branch before minting a candidate —
351
+ * the live-repeat incident this guards against: the checkout's `origin/preview`
352
+ * tracking ref was stale (recorded before a just-merged PR moved it), so a
353
+ * fresh `tot preview --new` built a candidate rooted in the OLD tip and got an
354
+ * instant, entirely avoidable "not mergeable" the moment it was compared
355
+ * against the real, already-advanced `preview`.
356
+ *
357
+ * Compares what the LOCAL checkout believes the base's tip is
358
+ * (`refs/remotes/origin/<branch>`, only as fresh as the last explicit fetch)
359
+ * against its ACTUAL current tip on the forge (`git ls-remote`, a lightweight
360
+ * single-ref read — no full fetch, no local ref mutated). Returns the live
361
+ * remote sha when local's cached view is behind it, or `null` when: the two
362
+ * already agree, there is no local tracking ref to compare against yet (a
363
+ * checkout that has simply never fetched this branch — never a false block on
364
+ * that), or the remote can't be reached right now (a network hiccup must
365
+ * never block a submit that would otherwise succeed; the push itself is the
366
+ * real connectivity test). Pure git I/O via the injected runner — unit-tested.
367
+ * @param {(cargs:string[])=>string} git
368
+ * @param {string} branch
369
+ * @returns {string|null}
370
+ */
371
+ export function detectStaleBase(git, branch) {
372
+ let localSha = "";
373
+ try {
374
+ localSha = git(["rev-parse", "-q", "--verify", `refs/remotes/origin/${branch}`]).trim();
375
+ } catch {
376
+ return null; // never fetched this branch locally — nothing cached to be stale
377
+ }
378
+ if (!localSha) return null;
379
+ let remoteSha = "";
380
+ try {
381
+ remoteSha = (git(["ls-remote", "origin", branch]).split(/\s+/)[0] || "").trim();
382
+ } catch {
383
+ return null; // can't reach the remote right now — don't block on a network hiccup
384
+ }
385
+ if (!remoteSha || remoteSha === localSha) return null;
386
+ return remoteSha;
387
+ }
388
+
329
389
  // ─── auto-commit the known content trees (unit u2) ───────────────────────────────
330
390
 
331
391
  /**
@@ -410,6 +470,59 @@ export function buildAutoCommitMessage({ message, files = [], statLine = "" } =
410
470
  return body.length ? `${subject}\n\n${body.join("\n")}` : subject;
411
471
  }
412
472
 
473
+ /**
474
+ * Detect an in-progress rebase, merge, or cherry-pick in the workspace (unit u8) —
475
+ * `tot preview`/`tot submit` must NEVER auto-commit over one of these. A rebase that
476
+ * stopped at "Could not apply" (or a merge/cherry-pick left with real conflicts) IS
477
+ * a dirty tree from `git status`'s point of view, so `autoCommitKnownTrees` would
478
+ * otherwise stage + commit the half-resolved content straight into a plain "content
479
+ * update" commit — silently finishing the git operation WRONG and losing whatever
480
+ * edit was still sitting in conflict markers or unresolved hunks (the live incident
481
+ * this guards against: a stalled rebase was never continued, `tot preview` ran
482
+ * anyway, and the developer's own edit was gone). Detection is worktree-safe:
483
+ * MERGE_HEAD/CHERRY_PICK_HEAD via plumbing refs (no direct `.git` path assumption),
484
+ * and rebase-merge/rebase-apply via `--git-path` (a linked worktree's git-dir lives
485
+ * OUTSIDE `<workspace>/.git`, so a literal `.git/rebase-merge` check would miss it).
486
+ * Returns which operation is in progress, or null when the tree is clean of one.
487
+ * @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
488
+ * @param {string} workspace absolute path `git` runs `-C` against — `--git-path`'s
489
+ * output may be relative, and Node's `existsSync` resolves relative paths against
490
+ * the CLI process's own cwd, not the checkout, so this pins the resolution base.
491
+ * @returns {"rebase"|"merge"|"cherry-pick"|null}
492
+ */
493
+ export function detectInProgressGitOperation(git, workspace) {
494
+ const hasRef = (name) => {
495
+ try {
496
+ return git(["rev-parse", "-q", "--verify", name]).trim().length > 0;
497
+ } catch {
498
+ return false;
499
+ }
500
+ };
501
+ if (hasRef("MERGE_HEAD")) return "merge";
502
+ if (hasRef("CHERRY_PICK_HEAD")) return "cherry-pick";
503
+ const hasGitPath = (name) => {
504
+ try {
505
+ const p = git(["rev-parse", "--git-path", name]).trim();
506
+ // An empty result should never happen for a real `--git-path` (it always
507
+ // echoes SOME path, existing or not) — but treat it as "absent" rather than
508
+ // resolving it, since `resolvePath(workspace, "")` degrades to `workspace`
509
+ // itself, which trivially always exists (a false "rebase in progress" on
510
+ // every call, not just an occasional false negative).
511
+ return p.length > 0 && existsSync(resolvePath(workspace, p));
512
+ } catch {
513
+ return false;
514
+ }
515
+ };
516
+ if (hasGitPath("rebase-merge") || hasGitPath("rebase-apply")) return "rebase";
517
+ return null;
518
+ }
519
+
520
+ /** The abort command that recovers from each in-progress git operation, so the
521
+ * refusal below can tell a developer exactly what to run. Pure. */
522
+ export function abortCommandFor(op) {
523
+ return op === "merge" ? "git merge --abort" : op === "cherry-pick" ? "git cherry-pick --abort" : "git rebase --abort";
524
+ }
525
+
413
526
  /**
414
527
  * Auto-commit the known content trees before previewing (unit u2). On a DIRTY
415
528
  * tree `tot preview` commits your content edits for you, so a preview always
@@ -420,16 +533,21 @@ export function buildAutoCommitMessage({ message, files = [], statLine = "" } =
420
533
  * (preview whatever's already committed — u1's behavior).
421
534
  *
422
535
  * 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.
536
+ * { skipped: true } — --no-commit.
537
+ * { inProgress: "rebase"|… } a rebase/merge/cherry-pick is unresolved;
538
+ * caller refuses rather than auto-committing
539
+ * over the developer's own half-resolved tree.
540
+ * { clean: true } — nothing dirty; preview HEAD as-is.
541
+ * { refused, unknown, known } — out-of-scope dirt; caller refuses + hints.
542
+ * { committed: true, sha, files } — staged the known dirty paths and committed.
427
543
  *
428
544
  * @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
429
- * @param {{ message?: string|null, noCommit?: boolean }} [opts]
545
+ * @param {{ message?: string|null, noCommit?: boolean, workspace?: string }} [opts]
430
546
  */
431
- export function autoCommitKnownTrees(git, { message = null, noCommit = false } = {}) {
547
+ export function autoCommitKnownTrees(git, { message = null, noCommit = false, workspace = "." } = {}) {
432
548
  if (noCommit) return { skipped: true };
549
+ const inProgress = detectInProgressGitOperation(git, workspace);
550
+ if (inProgress) return { inProgress };
433
551
  const status = git(["-c", "core.quotePath=false", "status", "--porcelain", "--untracked-files=all"]);
434
552
  const dirty = parsePorcelainPaths(status);
435
553
  if (dirty.length === 0) return { clean: true };
@@ -484,16 +602,17 @@ export function parseNameStatus(text) {
484
602
  * @returns {Array<{path: string, content?: string, contentEncoding?: "base64", delete?: true}>}
485
603
  */
486
604
  export function buildFilePatch(entries, readBlob) {
605
+ /** @type {Array<{ path: string, content?: string, contentEncoding?: "base64", delete?: true }>} */
487
606
  const patch = [];
488
607
  for (const e of entries) {
489
- if (e.status === "R") patch.push({ path: e.from, delete: true });
608
+ if (e.status === "R") patch.push({ path: /** @type {string} */ (e.from), delete: true });
490
609
  if (e.status === "D") {
491
610
  patch.push({ path: e.path, delete: true });
492
611
  continue;
493
612
  }
494
613
  const buf = readBlob(e.path);
495
614
  const asUtf8 = buf.toString("utf8");
496
- const isCleanUtf8 = !asUtf8.includes("") && Buffer.from(asUtf8, "utf8").equals(buf);
615
+ const isCleanUtf8 = !asUtf8.includes("\x00") && Buffer.from(asUtf8, "utf8").equals(buf);
497
616
  patch.push(
498
617
  isCleanUtf8
499
618
  ? { path: e.path, content: asUtf8 }
@@ -523,41 +642,14 @@ export function repoNameFromRemote(remoteUrl) {
523
642
 
524
643
  // ─── fresh-forge-credential push (decision B — the invited-dev 401 dead-end) ─────
525
644
 
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
- }
645
+ // splitAuthedRemote / basicAuthExtraHeader now live in ../git-credential.mjs (unit
646
+ // u10) a dependency-free module BOTH this file and clone.mjs/commands/
647
+ // git-credential.mjs need, so they moved out of here to avoid a submit.mjs
648
+ // clone.mjs import cycle. Re-exported so every existing import of these two names
649
+ // FROM submit.mjs (this file's own callers below, plus tests) keeps working
650
+ // unchanged.
651
+ export { splitAuthedRemote, basicAuthExtraHeader } from "../git-credential.mjs";
652
+ import { splitAuthedRemote, basicAuthExtraHeader, ensureTokenlessRemote } from "../git-credential.mjs";
561
653
 
562
654
  /**
563
655
  * Recognise a forge auth failure (expired / invalid push token) in a failed git
@@ -605,7 +697,7 @@ export function tagFromRepoName(repoName, tenant) {
605
697
  *
606
698
  * @param {(cargs:string[])=>string} git throwing git runner (execFileSync-backed)
607
699
  * @param {() => Promise<string|null>} mintRemote mints a fresh authed gitRemote (null when unavailable)
608
- * @param {{ ref: string }} opts
700
+ * @param {{ ref?: string }} [opts]
609
701
  * @returns {Promise<{ out: string }>} resolves on a successful push; throws (git's error) otherwise
610
702
  */
611
703
  export async function pushPreviewRef(git, mintRemote, { ref } = {}) {
@@ -710,6 +802,59 @@ export function chooseChangeId({ tenant, actorKey, branch = null, active = null,
710
802
  return { changeId, stableId, persist };
711
803
  }
712
804
 
805
+ /**
806
+ * The forge state of ONE candidate (`"open"`/`"merged"`/`"closed"`/…), read by
807
+ * changeId via `candidate_status`, or null when it can't be POSITIVELY determined —
808
+ * the candidate isn't found, carries no state, or the read throws. null ("couldn't
809
+ * tell") is the deliberately SAFE answer: the caller (resolveActivePointer) then
810
+ * behaves exactly as if the pointer were still live, so a purely-diagnostic check
811
+ * that can't run never blocks, changes, or crashes a submit (u17 acceptance #3).
812
+ * Tolerates the tool returning a bare candidate, a `{candidates:[…]}` list, or a
813
+ * plain array. Injectable client for tests.
814
+ * @param {{callTool:Function}} client
815
+ * @param {{ repo: string, changeId: string }} opts
816
+ * @returns {Promise<string|null>}
817
+ */
818
+ export async function candidateStateFor(client, { repo, changeId }) {
819
+ try {
820
+ const r = await client.callTool("candidate_status", { repo, changeId });
821
+ const c = Array.isArray(r)
822
+ ? r.find((x) => x?.changeId === changeId)
823
+ : Array.isArray(r?.candidates)
824
+ ? r.candidates.find((x) => x?.changeId === changeId)
825
+ : r;
826
+ return c && typeof c.state === "string" ? c.state : null;
827
+ } catch {
828
+ return null;
829
+ }
830
+ }
831
+
832
+ /**
833
+ * u17 — before REUSING a remembered active-candidate pointer, confirm its PR is
834
+ * still open. The live incident this guards against: after a candidate PR merged, a
835
+ * plain `tot preview` reused the remembered pointer, force-pushed onto the now-DEAD
836
+ * candidate branch (stale old-base history), and `candidate_open` opened a NEW PR
837
+ * from it — inheriting a guaranteed conflict from the very first commit. When the
838
+ * pointer's PR has gone terminal (merged/closed) we DROP it here, so `chooseChangeId`
839
+ * falls back to the stable per-branch id exactly as if no pointer existed.
840
+ *
841
+ * PURELY DIAGNOSTIC — never blocks a submit over the check itself: no pointer, no
842
+ * repo, or a check that errors / can't positively confirm terminal all resolve to
843
+ * `{ active }` UNCHANGED (behave exactly as before). Only a POSITIVELY terminal
844
+ * state drops the pointer. When it does, `dropped` carries the old changeId + the
845
+ * terminal state so the caller can tell the operator and forget the on-disk pointer.
846
+ * Injectable client for tests.
847
+ * @param {{callTool:Function}} client
848
+ * @param {{ repo: string|null, active: string|null }} opts
849
+ * @returns {Promise<{ active: string|null, dropped?: { changeId: string, state: string } }>}
850
+ */
851
+ export async function resolveActivePointer(client, { repo, active }) {
852
+ if (!active || !repo) return { active };
853
+ const state = await candidateStateFor(client, { repo, changeId: active });
854
+ if (isTerminalCandidateState(state)) return { active: null, dropped: { changeId: active, state: /** @type {string} */ (state) } };
855
+ return { active };
856
+ }
857
+
713
858
  /**
714
859
  * The ref `tot preview`/`tot submit` pushes to (b03 — stop force-pushing the
715
860
  * SHARED `preview` ref). An explicit `--ref` always wins — the escape hatch /
@@ -836,7 +981,7 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
836
981
  shipped: status?.shipped ?? null,
837
982
  dispatched: status?.dispatched ?? null,
838
983
  notDispatched: status?.notDispatched ?? false,
839
- forwardFailed: status?.forwardFailed ?? false,
984
+ forwardFailed: /** @type {any} */ (status)?.forwardFailed ?? false,
840
985
  delivery: status?.delivery ?? null,
841
986
  previewPrUrl,
842
987
  ...(error ? { error } : {}),
@@ -899,6 +1044,39 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
899
1044
  const tenant = ctx.tenant;
900
1045
  const git = (cargs) => execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
901
1046
 
1047
+ // Self-heal a LEGACY checkout (unit u10): strip any token still embedded in
1048
+ // `origin`'s URL and install the credential helper, so this run (and every one
1049
+ // after) mints fresh creds through `tot` instead of relying on one that quietly
1050
+ // expired. Best-effort — never blocks the actual preview on a migration hiccup.
1051
+ try {
1052
+ ensureTokenlessRemote(git);
1053
+ } catch {
1054
+ /* best-effort — see above */
1055
+ }
1056
+
1057
+ // Freshness preflight (unit u16) — BEFORE minting anything: is the checkout's
1058
+ // cached view of the base branch already behind the store? A candidate built
1059
+ // on a stale base is an instant, avoidable "not mergeable" the moment the
1060
+ // forge compares it against the real (already-advanced) base. --skip-freshness
1061
+ // opts out (e.g. offline/CI, or a deliberate re-run against a known-good tip).
1062
+ if (!args.skipFreshness) {
1063
+ let staleTip = null;
1064
+ try {
1065
+ staleTip = detectStaleBase(git, FRESHNESS_BASE_BRANCH);
1066
+ } catch {
1067
+ staleTip = null; // never block a submit on the preflight's OWN failure
1068
+ }
1069
+ if (staleTip) {
1070
+ const msg = `your checkout is behind the store — "${FRESHNESS_BASE_BRANCH}" has moved since your last sync`;
1071
+ console.error(
1072
+ 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`),
1073
+ );
1074
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1075
+ return 1;
1076
+ }
1077
+ }
1078
+
1079
+
902
1080
  // 0. auto-commit the known content trees (unit u2) — on a dirty tree, commit your
903
1081
  // content edits BEFORE previewing so the preview reflects your working changes.
904
1082
  // Only content/, public/, theme.json, .tot/ (staged by explicit path, never
@@ -906,7 +1084,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
906
1084
  // --no-commit opts out (preview whatever's already committed).
907
1085
  let auto;
908
1086
  try {
909
- auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit });
1087
+ auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit, workspace });
910
1088
  } catch (e) {
911
1089
  emitGitOp("commit", false, { command: verb, errorClass: "git_commit_failed" });
912
1090
  const msg = `auto-commit failed: ${String(e?.stderr || e?.message || e)}`;
@@ -915,6 +1093,20 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
915
1093
  return 1;
916
1094
  }
917
1095
  if (auto.committed) emitGitOp("commit", true, { command: verb });
1096
+ if (auto.inProgress) {
1097
+ const abortCmd = abortCommandFor(auto.inProgress);
1098
+ const msg = `a ${auto.inProgress} is still in progress here`;
1099
+ console.error(
1100
+ fail(
1101
+ msg,
1102
+ `finish it (resolve + continue) or back out (\`${abortCmd}\`), then re-run \`tot ${verb}\` — `
1103
+ + "auto-committing over an unresolved rebase/merge/cherry-pick would fold your half-resolved "
1104
+ + "tree into a plain content commit and can lose whatever edit was still unresolved",
1105
+ ),
1106
+ );
1107
+ emitJson(args, buildJsonResult({ ok: false, error: msg }));
1108
+ return 1;
1109
+ }
918
1110
  if (auto.refused) {
919
1111
  console.error(
920
1112
  fail(
@@ -937,6 +1129,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
937
1129
  // 1. validate locally — refuse on errors.
938
1130
  if (!args.skipValidate) {
939
1131
  const { ok, findings } = validateTenant(workspace, { tenantId: tenant, scope: ctx.config?.scope });
1132
+ // Advisory but LOUD: git conflict markers must never slip past as "validated"
1133
+ // (the half-resolved-rebase incident). Surfaced on the ok path too — warnings
1134
+ // are otherwise swallowed here — but they never block the submit.
1135
+ const conflicts = findings.filter((f) => f.rule === "git-conflict-markers");
1136
+ if (conflicts.length) {
1137
+ console.error(`\n⚠ git conflict markers in submitted content (${conflicts.length} file(s)) — an unfinished merge/rebase?`);
1138
+ for (const f of conflicts) console.error(` ⚠ ${f.file} — ${f.message}`);
1139
+ console.error(" The preview will still build, but it will serve the broken markers. Resolve before shipping.\n");
1140
+ }
940
1141
  if (!ok) {
941
1142
  const errs = findings.filter((f) => f.level === ERROR);
942
1143
  console.error(
@@ -1008,7 +1209,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1008
1209
  // needed — so they're available even on the no-session fallback path below.
1009
1210
  const branch = currentBranch(gitSafe);
1010
1211
  const statePath = defaultCandidateStatePath(env);
1011
- const active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
1212
+ let active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
1012
1213
 
1013
1214
  let session;
1014
1215
  try {
@@ -1059,6 +1260,30 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1059
1260
  return 0;
1060
1261
  }
1061
1262
 
1263
+ // u17 — before REUSING a remembered active pointer, confirm its PR is still open.
1264
+ // If it merged/closed we DROP it (and forget it on disk) so chooseChangeId falls
1265
+ // back to the stable id, rather than force-pushing onto a now-dead candidate branch
1266
+ // and opening a NEW PR that inherits a guaranteed conflict (the live incident this
1267
+ // guards against). Skipped under --new (chooseChangeId ignores `active` there
1268
+ // anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
1269
+ // Needs the tenant scope bound for candidate_status to resolve — idempotent with
1270
+ // the later client_switch / the fresh-mint checkoutTenant.
1271
+ if (active && repo && !args.new) {
1272
+ try {
1273
+ await client.callTool("client_switch", { tenant });
1274
+ } catch { /* scope bind is best-effort; candidateStateFor tolerates a miss */ }
1275
+ const resolved = await resolveActivePointer(client, { repo, active });
1276
+ if (resolved.dropped) {
1277
+ console.error(
1278
+ `~ remembered candidate ${resolved.dropped.changeId} is ${resolved.dropped.state} — dropping it and submitting fresh (a ${resolved.dropped.state} PR can't be reused).`,
1279
+ );
1280
+ try {
1281
+ clearActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch });
1282
+ } catch { /* best-effort local cleanup — a miss just re-checks next run */ }
1283
+ }
1284
+ active = resolved.active;
1285
+ }
1286
+
1062
1287
  // Which candidate (and therefore which isolated ref, b03) this submit targets —
1063
1288
  // decided now, with a real session, so the SAME id backs both the raw git push
1064
1289
  // (right below) and the PR-backed candidate (step 2b): the two never point at
@@ -1372,7 +1597,7 @@ export function shareablePrUrl(base, tenant, prNumber) {
1372
1597
  * @returns {string}
1373
1598
  */
1374
1599
  export function describeReadbackError(e) {
1375
- const msg = String(e?.message || e || "");
1600
+ const msg = String(/** @type {any} */ (e)?.message || e || "");
1376
1601
  const jsonStart = msg.indexOf("{");
1377
1602
  if (jsonStart >= 0 && msg.includes("self_repair")) {
1378
1603
  try {
@@ -1428,7 +1653,7 @@ export function formatShareableUrlBlock(s, tenant) {
1428
1653
  * back / re-submit" lie for the dead-end case: re-submitting cannot help, so we say
1429
1654
  * what actually happened and what to do, and never recommend another submit. Pure —
1430
1655
  * unit-tested. `verb` brands the copy with whatever the developer typed.
1431
- * @param {{ commit?: string|null, ref?: string|null }} ctx
1656
+ * @param {{ commit?: string|null, ref?: string|null, noChanges?: boolean }} ctx
1432
1657
  * @param {string} tenant @param {string} [verb]
1433
1658
  * @returns {string[]}
1434
1659
  */
@@ -1485,6 +1710,10 @@ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
1485
1710
  * --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
1486
1711
  * feed the honest never-dispatched block.
1487
1712
  */
1713
+ /**
1714
+ * @param {any} s @param {string} tenant
1715
+ * @param {{ open?: boolean, quiet?: boolean, commit?: string|null, ref?: string|null, verb?: string, noChanges?: boolean }} [opts]
1716
+ */
1488
1717
  function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview", noChanges = false } = {}) {
1489
1718
  if (!s || s.status === "unknown") {
1490
1719
  if (!quiet) {
@@ -31,6 +31,7 @@
31
31
  */
32
32
  import { execFileSync } from "node:child_process";
33
33
  import { fail } from "../errors.mjs";
34
+ import { ensureTokenlessRemote } from "../git-credential.mjs";
34
35
 
35
36
  /** The protected branch `tot sync` fetches + merges from by default. */
36
37
  export const DEFAULT_SYNC_BRANCH = "preview";
@@ -159,6 +160,16 @@ export async function run(argv, ctx) {
159
160
  }
160
161
  };
161
162
 
163
+ // Self-heal a LEGACY checkout (unit u10, absorbs u7): `tot login` refreshes
164
+ // this CLI's own session, never the token baked into a checkout's remote at
165
+ // clone time — the exact reason a stale checkout's `git fetch origin` (below)
166
+ // used to 401 even right after signing back in. Best-effort, never blocks sync.
167
+ try {
168
+ ensureTokenlessRemote(git);
169
+ } catch {
170
+ /* best-effort — see above */
171
+ }
172
+
162
173
  // Refuse a dirty tree up front — a merge on top of uncommitted edits is how
163
174
  // local work gets silently entangled with the merge, and we never sweep
164
175
  // anything in with `git add -A`. Commit or stash first, then re-run.
@@ -71,12 +71,13 @@ export function run(argv, ctx) {
71
71
  console.error(fail(target.error, "tot clone <tenant>, or pass --workspace <dir>"));
72
72
  return 2;
73
73
  }
74
- if (!existsSync(target.dir)) {
75
- console.error(fail(`no tenant directory at ${target.dir}`, "confirm the path, or `tot clone <tenant>`"));
74
+ const dir = /** @type {string} */ (target.dir);
75
+ if (!existsSync(dir)) {
76
+ console.error(fail(`no tenant directory at ${dir}`, "confirm the path, or `tot clone <tenant>`"));
76
77
  return 2;
77
78
  }
78
79
 
79
- const { ok, findings } = validateTenant(target.dir, {
80
+ const { ok, findings } = validateTenant(dir, {
80
81
  tenantId: target.tenantId ?? undefined,
81
82
  scope: target.scope ?? undefined,
82
83
  // A checkout / bare-tenant target is served by the ToT storefront platform, so
@@ -92,7 +93,12 @@ export function run(argv, ctx) {
92
93
 
93
94
  const errors = findings.filter((f) => f.level === ERROR);
94
95
  const warns = findings.filter((f) => f.level === WARN);
95
- console.log(`\ntot validate${target.tenantId ?? target.dir}\n`);
96
+ // Never present the checkout PATH as if it were the tenant name when the
97
+ // tenant couldn't be resolved (e.g. an invalid/missing .tot/config.json),
98
+ // the findings below say why; the header should say so too, not disguise
99
+ // a directory as a domain.
100
+ const label = target.tenantId ? target.tenantId : `(tenant unresolved) ${target.dir}`;
101
+ console.log(`\ntot validate — ${label}\n`);
96
102
  for (const f of findings) {
97
103
  const tag = f.level === ERROR ? "✗" : "⚠";
98
104
  console.log(` ${tag} [${f.rule}] ${f.file}\n ${f.message}${f.fix ? `\n → ${f.fix}` : ""}`);
@@ -35,7 +35,8 @@ const OS_INFO = { os: os.platform(), osVersion: os.release(), arch: os.arch() };
35
35
  * an activity URL and a bearer token. Never throws — a failed/offline hosted
36
36
  * worker just means the cockpit doesn't light up this beat.
37
37
  * @param {{ activityUrl?: string, token?: string, url?: string,
38
- * cliVersion?: string, runnerVersion?: string|null, editor?: string|null }} args
38
+ * cliVersion?: string, runnerVersion?: string|null, editor?: string|null,
39
+ * cwd?: string|null }} args
39
40
  */
40
41
  export function postHeartbeat({ activityUrl, token, url, cliVersion, runnerVersion, editor, cwd } = {}) {
41
42
  if (!activityUrl || !token) return undefined;
package/src/errors.mjs CHANGED
@@ -32,13 +32,17 @@ function versionFooter() {
32
32
 
33
33
  /**
34
34
  * A failure worth surfacing with a concrete next step.
35
+ * @typedef {object} CliErrorOpts
36
+ * @property {string} [next] - the exact command (or one-line instruction) to run next.
37
+ * @property {number} [exitCode] - process exit code to use (default 1).
38
+ * @property {unknown} [cause]
39
+ *
35
40
  * @param {string} what - what went wrong, in plain words.
36
- * @param {{ next?: string, exitCode?: number, cause?: unknown }} [opts]
37
- * next - the exact command (or one-line instruction) to run next.
38
- * exitCode - process exit code to use (default 1).
41
+ * @param {CliErrorOpts} [opts]
39
42
  */
40
43
  export class CliError extends Error {
41
- constructor(what, { next, exitCode = 1, cause } = {}) {
44
+ constructor(what, opts = {}) {
45
+ const { next, exitCode = 1, cause } = opts;
42
46
  super(what);
43
47
  this.name = "CliError";
44
48
  this.what = what;