@tokenoftrust/cli 2.0.11 → 2.0.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.0.11",
3
+ "version": "2.0.12",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -3,9 +3,10 @@
3
3
  * updates, per forge repo.
4
4
  *
5
5
  * `tot submit` is idempotent on a STABLE changeId (`deriveChangeId`) so a re-submit
6
- * updates the same PR by default the common case needs NO state and writes
7
- * nothing here (backward-compatible with the stateless original). This file only
8
- * records a DIVERGENCE from that stable default:
6
+ * updates the same PR by default. Every chosen identity is persisted before its
7
+ * ref is pushed, which makes a failed push or MCP registration retry the same ref.
8
+ * The stored pointer is especially important when the chosen id diverges from the
9
+ * stable default:
9
10
  *
10
11
  * - `tot submit --fork-candidate` forks a fresh candidate and remembers it here, so the
11
12
  * NEXT plain `tot submit` keeps updating the NEW PR (like pushing more commits
@@ -18,14 +19,13 @@
18
19
  * candidates): a different MCP, repo, OR non-default git branch is a different
19
20
  * candidate namespace, so a feature branch gets its OWN candidate PR instead of
20
21
  * fighting main's over the same handle. The default branch deliberately keeps the
21
- * OLD branch-less key so existing devs' state is byte-identical (zero migration),
22
- * and a branch-scoped read that misses FALLS BACK to that old key so state written
23
- * before the rekey (or by the default branch) is never orphaned. Same atomic-write
24
- * discipline as last-tenant.mjs (0600 in a 0700 dir, write-tmp-then-rename).
22
+ * branch-less key. Non-default branches never inherit that pointer. Updates hold an
23
+ * exclusive lock across read-modify-write so concurrent submits cannot lose either
24
+ * identity. Files use mode 0600 in a 0700 directory.
25
25
  * Dependency-free (node:fs/os/path). `TOT_HOME` overrides home (tests).
26
26
  */
27
27
  import {
28
- readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync,
28
+ readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, rmdirSync, statSync,
29
29
  } from "node:fs";
30
30
  import { homedir } from "node:os";
31
31
  import { join, dirname } from "node:path";
@@ -79,45 +79,70 @@ function readMap(filePath) {
79
79
  }
80
80
 
81
81
  function writeMap(filePath, map) {
82
- mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
83
- const tmp = `${filePath}.tmp`;
82
+ const tmp = `${filePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
84
83
  writeFileSync(tmp, `${JSON.stringify(map, null, 2)}\n`, { mode: 0o600 });
85
84
  renameSync(tmp, filePath);
86
85
  chmodSync(filePath, 0o600);
87
86
  }
88
87
 
88
+ const LOCK_RETRY_MS = 10;
89
+ const LOCK_ATTEMPTS = 200;
90
+ const STALE_LOCK_MS = 30_000;
91
+ const lockWait = new Int32Array(new SharedArrayBuffer(4));
92
+
93
+ function updateMap(filePath, mutate) {
94
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
95
+ const lockPath = `${filePath}.lock`;
96
+ let locked = false;
97
+ for (let attempt = 0; attempt < LOCK_ATTEMPTS && !locked; attempt += 1) {
98
+ try {
99
+ mkdirSync(lockPath, { mode: 0o700 });
100
+ locked = true;
101
+ } catch (error) {
102
+ if (error?.code !== "EEXIST") throw error;
103
+ try {
104
+ if (Date.now() - statSync(lockPath).mtimeMs > STALE_LOCK_MS) rmdirSync(lockPath);
105
+ } catch (staleError) {
106
+ if (staleError?.code !== "ENOENT" && staleError?.code !== "ENOTEMPTY") throw staleError;
107
+ }
108
+ if (!locked) Atomics.wait(lockWait, 0, 0, LOCK_RETRY_MS);
109
+ }
110
+ }
111
+ if (!locked) throw new Error(`Candidate state is busy: ${lockPath}`);
112
+ try {
113
+ const map = readMap(filePath);
114
+ if (mutate(map)) writeMap(filePath, map);
115
+ } finally {
116
+ rmdirSync(lockPath);
117
+ }
118
+ }
119
+
89
120
  /**
90
121
  * The remembered active changeId for `(mcpUrl, repo, branch)`, or null when there
91
122
  * isn't one (absent/unreadable/malformed) — a miss means "use the stable default".
92
- * On a non-default branch whose branch-scoped key misses, FALLS BACK to the legacy
93
- * branch-less key so state written before the rekey (or by the default branch)
94
- * isn't orphaned. Never throws.
123
+ * Non-default branches never read the default branch's pointer. Never throws.
95
124
  */
96
125
  export function readActiveChangeId(filePath, { mcpUrl, repo, branch }) {
97
126
  const map = readMap(filePath);
98
- const primary = recordChangeId(map[stateKey(mcpUrl, repo, branch)]);
99
- if (primary) return primary;
100
- // Legacy fallback: a branch-scoped miss reads the old branch-less key (a no-op
101
- // when we're already on the default branch, which IS the legacy key).
102
- if (!isDefaultBranch(branch)) return recordChangeId(map[legacyStateKey(mcpUrl, repo)]);
103
- return null;
127
+ return recordChangeId(map[stateKey(mcpUrl, repo, branch)]);
104
128
  }
105
129
 
106
130
  /** Remember `changeId` as the active candidate for `(mcpUrl, repo, branch)`, atomically. */
107
131
  export function writeActiveChangeId(filePath, { mcpUrl, repo, branch, changeId }) {
108
- const map = readMap(filePath);
109
- map[stateKey(mcpUrl, repo, branch)] = { changeId, updatedAt: Date.now() };
110
- writeMap(filePath, map);
132
+ updateMap(filePath, (map) => {
133
+ map[stateKey(mcpUrl, repo, branch)] = { changeId, updatedAt: Date.now() };
134
+ return true;
135
+ });
111
136
  }
112
137
 
113
138
  /** Forget the active candidate for `(mcpUrl, repo, branch)` (e.g. after closing it). */
114
139
  export function clearActiveChangeId(filePath, { mcpUrl, repo, branch }) {
115
- const map = readMap(filePath);
116
- const key = stateKey(mcpUrl, repo, branch);
117
- if (key in map) {
140
+ updateMap(filePath, (map) => {
141
+ const key = stateKey(mcpUrl, repo, branch);
142
+ if (!(key in map)) return false;
118
143
  delete map[key];
119
- writeMap(filePath, map);
120
- }
144
+ return true;
145
+ });
121
146
  }
122
147
 
123
148
  /**
@@ -4,9 +4,8 @@
4
4
  * From inside a tenant checkout:
5
5
  * 1. validate locally and refuse on errors (fail fast before anything leaves the machine),
6
6
  * 2. push your committed work to YOUR OWN isolated candidate ref — `candidate/<changeId>`
7
- * (b03 — no shared `preview` ref is ever force-pushed; an explicit `--ref` can still
8
- * target a literal ref name for back-compat) which triggers reconcile,
9
- * 2b. open/update a PR-BACKED CANDIDATE for the same committed diff (g1b's
7
+ * (b03 — no shared `preview` ref is ever force-pushed) which triggers reconcile,
8
+ * 2b. register that exact ref + asserted HEAD as a PR-BACKED CANDIDATE (g1b's
10
9
  * `candidate_open`, unit c1 — the local-dev-loop half of the "PR-Backed Hosted
11
10
  * Review Loop" milestone, symmetric with the hosted s6 draft-as-PR path), and
12
11
  * print the resulting changeId/PR number/URL,
@@ -14,14 +13,11 @@
14
13
  *
15
14
  * This is submit-for-PREVIEW, not ship-to-live (`change_accept`/`candidate_accept` /
16
15
  * a future `tot ship` is the separate ship gate — this command is submit-only, never
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
16
+ * accept/reject). Step 2b fails closed: a push without an exact, verified PR registration
17
+ * exits non-zero and keeps its stable retry identity. Step 3 calls
21
18
  * the MCP `preview_status` read-back: given the commit just pushed it returns
22
19
  * { status, reconcile, compliance, previewUrl } and we poll it while reconcile is
23
- * pending. If that tool isn't present (older MCP) the command still validates +
24
- * pushes and reports "reconcile pending" — degrading visibly, never a crash.
20
+ * pending. A read-back failure after the candidate exists is reported separately.
25
21
  *
26
22
  * Polling (E2): every call carries `waitMs` so a preview_status-aware MCP long-polls
27
23
  * (blocks up to waitMs, waking immediately on arrival) instead of us sleeping blind
@@ -161,7 +157,7 @@ export function renderUsage(verb = "preview") {
161
157
  merge, surfaces a resolve card on a genuine overlap — the
162
158
  default), "ours" (keep yours), "theirs" (keep the store's)
163
159
  tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
164
- tot ${verb} --ref <name> push ref (default: your own isolated candidate ref see \`tot pr\`)
160
+ tot ${verb} --ref <name> assert the generated candidate/<changeId> ref (custom refs are refused)
165
161
  tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
166
162
  tot ${verb} --summary "<text>" longer description to accompany the title
167
163
  tot ${verb} --summary-file <path> structured summary from a file — JSON
@@ -446,15 +442,9 @@ export function baseCommitsBehind(git, branch) {
446
442
  }
447
443
 
448
444
  /**
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
445
+ * Resolve the git ref used to summarize the candidate's complete delta. Diffing
446
+ * against the already-pushed candidate tracking ref would be empty on retry even
447
+ * though the change vs preview is non-empty. Prefer the merge-base (fork point) with
458
448
  * `origin/<baseBranch>`; fall back to the candidate tracking ref, then HEAD~1, then ""
459
449
  * (single-commit `git show`) when no base ref resolves. Pure git I/O via the injected
460
450
  * runner — unit-tested.
@@ -766,38 +756,6 @@ export function parseNameStatus(text) {
766
756
  .filter((e) => e.path);
767
757
  }
768
758
 
769
- /**
770
- * Build the g1b `candidate_open` FileChange[] patch from parsed name-status
771
- * entries — one entry per changed path, `delete:true` for removals (and a
772
- * rename's old path). Content is read from the committed HEAD blob via
773
- * `readBlob` (injected — never the working tree, so the patch matches exactly
774
- * what was pushed). Binary content (doesn't round-trip as clean utf8, or
775
- * contains a NUL) is sent base64; everything else goes as plain utf8 text.
776
- * @param {{status:string, path:string, from?:string}[]} entries
777
- * @param {(path: string) => Buffer} readBlob
778
- * @returns {Array<{path: string, content?: string, contentEncoding?: "base64", delete?: true}>}
779
- */
780
- export function buildFilePatch(entries, readBlob) {
781
- /** @type {Array<{ path: string, content?: string, contentEncoding?: "base64", delete?: true }>} */
782
- const patch = [];
783
- for (const e of entries) {
784
- if (e.status === "R") patch.push({ path: /** @type {string} */ (e.from), delete: true });
785
- if (e.status === "D") {
786
- patch.push({ path: e.path, delete: true });
787
- continue;
788
- }
789
- const buf = readBlob(e.path);
790
- const asUtf8 = buf.toString("utf8");
791
- const isCleanUtf8 = !asUtf8.includes("\x00") && Buffer.from(asUtf8, "utf8").equals(buf);
792
- patch.push(
793
- isCleanUtf8
794
- ? { path: e.path, content: asUtf8 }
795
- : { path: e.path, content: buf.toString("base64"), contentEncoding: "base64" },
796
- );
797
- }
798
- return patch;
799
- }
800
-
801
759
  /**
802
760
  * Derive the g1b forge repo name (`"<tenant>-<tag>"`, e.g. `"acme.com-main"`)
803
761
  * from the checkout's authenticated origin remote — the SAME name
@@ -1034,17 +992,18 @@ export async function resolveActivePointer(client, { repo, active }) {
1034
992
 
1035
993
  /**
1036
994
  * The ref `tot preview`/`tot submit` pushes to (b03 — stop force-pushing the
1037
- * SHARED `preview` ref). An explicit `--ref` always wins the escape hatch /
1038
- * back-compat path (e.g. `--ref preview` reproduces the old shared-ref push for
1039
- * any tooling that still reads it by that literal name); otherwise it's YOUR OWN
1040
- * isolated candidate ref, so two developers — or the same developer on two
1041
- * branches — never force-push over each other or each other's preview. Pure —
1042
- * unit-tested.
995
+ * SHARED `preview` ref). The only valid target is the exact ref derived from the
996
+ * stable candidate identity. `--ref` may assert that value, but cannot override it.
997
+ * Pure unit-tested.
1043
998
  * @param {{ ref?: string|null, changeId: string }} opts
1044
999
  * @returns {string}
1045
1000
  */
1046
1001
  export function resolvePushRef({ ref, changeId }) {
1047
- return ref || candidateRefFor(changeId);
1002
+ const expected = candidateRefFor(changeId);
1003
+ if (ref && ref !== expected) {
1004
+ throw new Error(`--ref must be exactly ${expected}; custom refs cannot be registered as this candidate.`);
1005
+ }
1006
+ return expected;
1048
1007
  }
1049
1008
 
1050
1009
  const REQUIRED_ATTRIBUTION_REFUSALS = new Set([
@@ -1052,40 +1011,32 @@ const REQUIRED_ATTRIBUTION_REFUSALS = new Set([
1052
1011
  "audit_unavailable",
1053
1012
  ]);
1054
1013
 
1055
- class CandidateAttributionError extends Error {
1014
+ export class CandidateRegistrationError extends Error {
1056
1015
  constructor(message) {
1057
1016
  super(message);
1058
- this.name = "CandidateAttributionError";
1017
+ this.name = "CandidateRegistrationError";
1059
1018
  }
1060
1019
  }
1061
1020
 
1062
1021
  /**
1063
1022
  * Open/update the PR-backed candidate for this submit (g1b `candidate_open`,
1064
1023
  * unit c1 — the local-dev-loop half of the "PR-Backed Hosted Review Loop"
1065
- * milestone, symmetric with the hosted s6 draft-as-PR path). Builds the patch
1066
- * from the SAME name-status diff `buildChangeSummary` reports on, reuses its
1067
- * title/body as the PR title/description, and prints the resulting
1068
- * changeId/PR number/URL. Best-effort: any failure (no repo could be derived,
1069
- * older MCP, version control not configured, preview-access capability, …) is
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.
1024
+ * milestone, symmetric with the hosted s6 draft-as-PR path). The Git-capable CLI
1025
+ * has already pushed its real commits, so this call sends only the exact candidate
1026
+ * ref and asserted HEAD plus PR metadata. Any registration failure is fatal: a
1027
+ * pushed ref without a review PR is not a successful submit.
1072
1028
  * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
1073
- * @param {{ repo: string|null, changeId: string, changeSummary: {title:string, body:string[]},
1074
- * patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer,
1029
+ * @param {{ repo: string|null, changeId: string, ref: string, headSha: string,
1030
+ * changeSummary: {title:string, body:string[]}, hasChanges: boolean,
1075
1031
  * quiet?: boolean }} opts `quiet` (--json) suppresses the human print; the same
1076
1032
  * result is still returned for the caller's JSON payload.
1077
1033
  */
1078
- export async function submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet = false }) {
1034
+ export async function submitCandidate(client, { repo, changeId, ref, headSha, changeSummary, hasChanges, quiet = false }) {
1079
1035
  if (!repo) {
1080
- if (!quiet) console.log(` ~ couldn't derive the forge repo from the checkout's remote — skipping the PR-backed candidate.`);
1081
- return null;
1036
+ throw new CandidateRegistrationError("Candidate not created: couldn't derive the forge repo from origin.");
1082
1037
  }
1038
+ if (!hasChanges) throw new CandidateRegistrationError("Candidate not created: no content changes were found.");
1083
1039
  try {
1084
- const patch = buildFilePatch(patchEntries, readBlob);
1085
- if (patch.length === 0) {
1086
- if (!quiet) console.log(` ~ no file changes to open a PR-backed candidate for.`);
1087
- return null;
1088
- }
1089
1040
  const result = await client.callTool("candidate_open", {
1090
1041
  repo,
1091
1042
  changeId,
@@ -1094,22 +1045,33 @@ export async function submitCandidate(client, { repo, changeId, changeSummary, p
1094
1045
  // truncated PR title/body instead of an entirely-avoidable tool refusal.
1095
1046
  title: changeSummary.title.slice(0, 200),
1096
1047
  body: changeSummary.body.length ? changeSummary.body.join("\n").slice(0, 4000) : undefined,
1097
- patch,
1048
+ ref,
1049
+ headSha,
1050
+ idempotencyKey: `candidate-open:${repo}:${changeId}:${headSha}`,
1098
1051
  });
1099
- if (REQUIRED_ATTRIBUTION_REFUSALS.has(result?.status)) {
1100
- throw new CandidateAttributionError(
1101
- result?.message || "Candidate not created: verified actor attribution is required.",
1052
+ if (REQUIRED_ATTRIBUTION_REFUSALS.has(result?.status) || result?.status === "error") {
1053
+ throw new CandidateRegistrationError(
1054
+ result?.message || "Candidate not created: the MCP refused registration.",
1055
+ );
1056
+ }
1057
+ if (typeof result?.prNumber !== "number") {
1058
+ throw new CandidateRegistrationError(
1059
+ result?.message || "Candidate not created: candidate_open returned no pull request.",
1102
1060
  );
1103
1061
  }
1062
+ if (result.branch !== ref || result.headSha !== headSha) {
1063
+ throw new CandidateRegistrationError(
1064
+ `Candidate not created: MCP registered ${result.branch || "an unknown ref"}@${result.headSha || "unknown"}, expected ${ref}@${headSha}.`,
1065
+ );
1066
+ }
1067
+ if (isTerminalCandidateState(result.state)) {
1068
+ throw new CandidateRegistrationError(`Candidate not created: candidate_open returned terminal state ${result.state}.`);
1069
+ }
1104
1070
  reportCandidate(result, changeId, { quiet });
1105
1071
  return result;
1106
1072
  } catch (e) {
1107
- if (e instanceof CandidateAttributionError) throw e;
1108
- if (!quiet) {
1109
- console.log(` ~ couldn't open/update the PR-backed candidate: ${String(e?.message || e)}`);
1110
- console.log(` (best-effort — your push is still in; this doesn't block reconcile.)`);
1111
- }
1112
- return null;
1073
+ if (e instanceof CandidateRegistrationError) throw e;
1074
+ throw new CandidateRegistrationError(`Candidate not created: ${describeReadbackError(e)}`);
1113
1075
  }
1114
1076
  }
1115
1077
 
@@ -1247,10 +1209,18 @@ function emitJson(args, payload) {
1247
1209
  * `--json` (args.json) suppresses the human-readable stdout narration in favor of
1248
1210
  * one structured result object at the end (see buildJsonResult) — stderr
1249
1211
  * diagnostics (fail(), `~ …` progress lines) still print either way.
1250
- * @param {string[]} argv @param {any} ctx @param {{ verb?: string }} [opts]
1212
+ * @param {string[]} argv @param {any} ctx
1213
+ * @param {{ verb?: string, env?: NodeJS.ProcessEnv,
1214
+ * createClient?: typeof createMcpClient, establish?: typeof establishSession,
1215
+ * checkout?: typeof checkoutTenant }} [opts]
1251
1216
  */
1252
- export async function run(argv, ctx, { verb = "preview" } = {}) {
1253
- const env = process.env;
1217
+ export async function run(argv, ctx, {
1218
+ verb = "preview",
1219
+ env = process.env,
1220
+ createClient = createMcpClient,
1221
+ establish = establishSession,
1222
+ checkout = checkoutTenant,
1223
+ } = {}) {
1254
1224
  const args = parseArgs(argv);
1255
1225
  if (args.help) {
1256
1226
  console.log(renderUsage(verb));
@@ -1441,9 +1411,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1441
1411
  // push, because `git push` fast-forwards the local origin/<ref> tracking ref and
1442
1412
  // would zero out the "vs what's live in preview" diff. Explicit -m/--summary win;
1443
1413
  // otherwise it's generated from git so the change record is never blank. This
1444
- // same diff (name-status, so candidate_open also knows adds/deletes/renames)
1445
- // doubles as the source of the PR-backed candidate's file patch (step 2b, below)
1446
- // — one git read, two consumers, so the PR always matches what's printed here.
1414
+ // same diff supplies the PR summary and the pre-push no-change gate.
1447
1415
  // Parameterized on `ref` (b03 — isolated candidate refs): the diff base is YOUR
1448
1416
  // candidate ref's own tracking ref, not a shared one, so the summary always reads
1449
1417
  // "vs your own last push" once the push ref is known (computed just below).
@@ -1454,85 +1422,45 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1454
1422
  return "";
1455
1423
  }
1456
1424
  };
1457
- function buildSummaryAndPatch(ref) {
1425
+ function buildSubmitSummary(ref) {
1458
1426
  const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
1459
- // Diff against the fork point off `preview` the FULL branch delta — so the patch
1460
- // is complete for candidate_open's server-cut (which resets the branch to preview
1461
- // and re-applies this patch). NOT the candidate's own tracking ref: after a push
1462
- // that landed but failed to open its PR, that ref equals HEAD → empty patch →
1463
- // wrong "no file changes" → no PR. See resolvePatchBase.
1427
+ // Diff against the fork point off `preview` so retries keep the complete summary
1428
+ // even when the previously pushed candidate tracking ref already equals HEAD.
1464
1429
  const base = resolvePatchBase(gitSafe, ref);
1465
1430
  const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
1466
- const patchEntries = parseNameStatus(gitSafe(statusCmd));
1467
- const files = patchEntries.map((e) => e.path);
1431
+ const changedEntries = parseNameStatus(gitSafe(statusCmd));
1432
+ const files = changedEntries.map((e) => e.path);
1468
1433
  const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
1469
1434
  const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
1470
- return { changeSummary, patchEntries };
1435
+ return { changeSummary, changedEntries };
1471
1436
  }
1472
1437
 
1473
1438
  // The MCP session is needed BOTH to mint a fresh forge push credential (decision
1474
1439
  // B — right below) and for the candidate/preview read-back after, so establish it
1475
1440
  // ONCE, up front, and reuse it for the whole flow.
1476
1441
  const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
1477
- const client = createMcpClient(baseUrl);
1442
+ const client = createClient(baseUrl);
1478
1443
  const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
1479
1444
 
1480
1445
  // Branch-bound (u4) + the isolated candidate ref (b03): resolved BEFORE the push,
1481
1446
  // since the push target itself depends on it — so the raw git push and the
1482
1447
  // PR-backed candidate (step 2b, below) always land on the SAME branch. `active`/
1483
1448
  // `statePath` are local filesystem reads (candidate-state.mjs) — no session
1484
- // needed — so they're available even on the no-session fallback path below.
1449
+ // needed — so they're available before any remote write.
1485
1450
  const branch = currentBranch(gitSafe);
1486
1451
  const statePath = defaultCandidateStatePath(env);
1487
1452
  let active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
1488
1453
 
1489
1454
  let session;
1490
1455
  try {
1491
- session = await establishSession(client, { env, prefer: args.identity || undefined });
1456
+ session = await establish(client, { env, prefer: args.identity || undefined });
1492
1457
  } catch (e) {
1493
- // Not signed in / MCP unreachable — we can't mint a fresh credential, so fall
1494
- // back to pushing over the checkout's EXISTING embedded remote (pre-B behavior:
1495
- // no worse than before) and skip the read-back that needs a session. The push
1496
- // still lands if that embedded token is live. actorKeyFor(null) degrades to the
1497
- // generic "developer" key — still isolated PER BRANCH (never the shared ref),
1498
- // just not per-developer until sign-in succeeds.
1499
- const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, forkCandidate: args.forkCandidate });
1500
- const ref = resolvePushRef({ ref: args.ref, changeId });
1501
- const { changeSummary } = buildSummaryAndPatch(ref);
1502
- console.error(`~ pushing ${short} → ${ref} (origin)`);
1503
- try {
1504
- const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
1505
- if (out.trim()) console.error(redactUrl(out.trim()));
1506
- emitGitOp("push", true, { command: verb });
1507
- } catch (pushErr) {
1508
- emitGitOp("push", false, {
1509
- command: verb,
1510
- errorClass: isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr) ? "forge_auth" : "git_push_failed",
1511
- });
1512
- const msg = `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`;
1513
- // This is the NO-SESSION path pushing the clone-time embedded credential —
1514
- // which rotation kills the moment any fresh mint happens elsewhere. An auth
1515
- // failure here is therefore almost always "you're not signed in IN THIS
1516
- // SHELL", not a network problem; the old remote-is-reachable hint sent a
1517
- // human down the wrong path live (Trello-13075 polish).
1518
- const hint = isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr)
1519
- ? "you're not signed in in this shell, and the checkout's embedded credential has likely been rotated — run `tot login` (check TOT_PROFILE if you use per-terminal identities), then re-run"
1520
- : "check your commit and that the checkout's remote is reachable, then re-run";
1521
- console.error(fail(msg, hint));
1522
- emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
1523
- return 1;
1524
- }
1525
- if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
1526
- printChangeSummary(changeSummary, { quiet: args.json });
1527
1458
  const note = e instanceof AuthUnavailableError
1528
- ? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
1529
- : `couldn't reach Token of Trust for the result read-back: ${describeReadbackError(e)}`;
1530
- if (!args.json) {
1531
- console.log(` (${note})`);
1532
- console.log(` Your push is in; the preview updates once reconcile completes.`);
1533
- }
1534
- emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
1535
- return 0;
1459
+ ? `Candidate not created: ${e.hint || "developer sign-in is required"}.`
1460
+ : `Candidate not created: couldn't reach Token of Trust (${describeReadbackError(e)}).`;
1461
+ if (!args.json) console.error(fail(note, "run `tot login`, then re-run; no candidate ref was pushed"));
1462
+ emitJson(args, buildJsonResult({ ok: false, commit, error: note }));
1463
+ return 1;
1536
1464
  }
1537
1465
 
1538
1466
  // u17 — before REUSING a remembered active pointer, confirm its PR is still open.
@@ -1564,9 +1492,41 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1564
1492
  // (right below) and the PR-backed candidate (step 2b): the two never point at
1565
1493
  // different branches. See chooseChangeId's doc for the --fork-candidate /
1566
1494
  // active-pointer rules.
1567
- let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, forkCandidate: args.forkCandidate });
1568
- const ref = resolvePushRef({ ref: args.ref, changeId });
1569
- const { changeSummary, patchEntries } = buildSummaryAndPatch(ref);
1495
+ const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, forkCandidate: args.forkCandidate });
1496
+ let ref;
1497
+ try {
1498
+ ref = resolvePushRef({ ref: args.ref, changeId });
1499
+ } catch (e) {
1500
+ const note = String(e?.message || e);
1501
+ if (!args.json) console.error(fail(note, "omit --ref and re-run"));
1502
+ emitJson(args, buildJsonResult({ ok: false, commit, changeId, error: note }));
1503
+ return 1;
1504
+ }
1505
+ const { changeSummary, changedEntries } = buildSubmitSummary(ref);
1506
+ if (changedEntries.length === 0) {
1507
+ const note = "Candidate not created: no content changes were found.";
1508
+ if (!args.json) console.error(fail(note, "commit a content/public/theme.json change, then re-run"));
1509
+ emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note, noChanges: true }));
1510
+ return 1;
1511
+ }
1512
+
1513
+ // Persist the chosen identity before any remote write. A failed push or MCP
1514
+ // registration therefore retries the same candidate/ref instead of minting an
1515
+ // orphan. Failing to persist a forked identity is itself unsafe, so refuse.
1516
+ if (!repo) {
1517
+ const note = "Candidate not created: couldn't derive the forge repo from origin.";
1518
+ if (!args.json) console.error(fail(note, "run this command inside a `tot clone` checkout"));
1519
+ emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
1520
+ return 1;
1521
+ }
1522
+ try {
1523
+ writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
1524
+ } catch {
1525
+ const note = "Candidate not created: couldn't persist retry identity.";
1526
+ if (!args.json) console.error(fail(note, "fix ~/.tot permissions, then re-run; no candidate ref was pushed"));
1527
+ emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
1528
+ return 1;
1529
+ }
1570
1530
 
1571
1531
  // 2. push the preview ref with a FRESHLY-MINTED, short-lived forge credential
1572
1532
  // (decision B). The token baked into `.git/config` at clone time expires within
@@ -1577,7 +1537,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1577
1537
  const tag = tagFromRepoName(repo, tenant);
1578
1538
  const mintRemote = async () => {
1579
1539
  try {
1580
- const res = await checkoutTenant(client, { tenant, tag, cloneDir: null, redact: redactUrl });
1540
+ const res = await checkout(client, { tenant, tag, cloneDir: null, redact: redactUrl });
1581
1541
  return res.gitRemote || null;
1582
1542
  } catch (e) {
1583
1543
  console.error(
@@ -1611,28 +1571,19 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1611
1571
  try {
1612
1572
  // Bind the active tenant so preview_status/candidate_open read the right scope
1613
1573
  // (idempotent — checkoutTenant already switched when the fresh mint succeeded).
1614
- await client.callTool("client_switch", { tenant });
1615
-
1616
- // 2b. PR-backed candidate (g1b candidate_open, unit c1). Compatibility and
1617
- // capability failures remain best-effort, but the MCP's audit/identity refusals
1618
- // propagate and make this command fail: an unaudited PR is never an acceptable
1619
- // successful submit.
1620
- // `changeId`/`stableId`/`persist` were already decided above (they picked the
1621
- // push ref too); if the chosen candidate turns out to be merged/closed, roll to
1622
- // a fresh one so a re-submit is never wedged on a dead PR. (`repo` was derived
1623
- // above.)
1624
- const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
1625
-
1626
- let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
1627
-
1628
- if (candidate && isTerminalCandidateState(candidate.state)) {
1629
- const rolled = mintFreshChangeId(stableId);
1630
- if (!args.json) console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
1631
- changeId = rolled;
1632
- persist = true;
1633
- candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
1574
+ try {
1575
+ await client.callTool("client_switch", { tenant });
1576
+ } catch (e) {
1577
+ throw new CandidateRegistrationError(`Candidate not created: tenant scope could not be bound (${describeReadbackError(e)}).`);
1634
1578
  }
1635
1579
 
1580
+ // 2b. Register the exact ref and asserted commit that git just pushed. No file
1581
+ // bytes cross MCP and the server does not reconstruct or replace these commits.
1582
+ const candidate = await submitCandidate(client, {
1583
+ repo, changeId, ref, headSha: commit, changeSummary,
1584
+ hasChanges: changedEntries.length > 0, quiet: args.json,
1585
+ });
1586
+
1636
1587
  // c3 — BORN-REBASED at submit (shift-left prevention #2). The candidate is open;
1637
1588
  // if the base has drifted (the SAME signal c2 warned on, pre-push) rebuild it from
1638
1589
  // the CURRENT base tip via candidate_refresh BEFORE finalizing, so it enters the
@@ -1663,13 +1614,6 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1663
1614
  if (freshPrUrl && !args.json) {
1664
1615
  console.log(`\n ▸ Your fresh preview will appear at:\n ${freshPrUrl}\n (building on the current store — this link goes live once reconcile completes)`);
1665
1616
  }
1666
- // The rebuild keeps the STABLE changeId, so the persisted active pointer stays
1667
- // valid — record it (best-effort) exactly as the normal open path does below.
1668
- if (persist && repo) {
1669
- try {
1670
- writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
1671
- } catch { /* best-effort local hint */ }
1672
- }
1673
1617
  emitJson(args, buildJsonResult({
1674
1618
  ok: true, ref, commit, changeId,
1675
1619
  candidate: { ...candidate, prNumber: freshPr ?? candidate.prNumber },
@@ -1703,14 +1647,6 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1703
1647
  console.log(`\n ▸ Your preview will appear at:\n ${previewPrUrl}\n (building — this link goes live once reconcile completes)`);
1704
1648
  }
1705
1649
 
1706
- // Remember the active candidate only on a real, non-terminal open (best-effort;
1707
- // never let a state-write failure break the submit).
1708
- if (persist && repo && candidate && !isTerminalCandidateState(candidate.state)) {
1709
- try {
1710
- writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
1711
- } catch { /* best-effort local hint — a miss just re-derives the stable id */ }
1712
- }
1713
-
1714
1650
  // Poll the sha `preview_status` actually resolves against — the candidate head, not
1715
1651
  // the local commit, whenever the candidate names its own. Printed first (when they
1716
1652
  // differ) so the developer reads the result against the right sha.
@@ -1763,27 +1699,25 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1763
1699
  // — automation doesn't want a browser popping up.
1764
1700
  // `commit: statusSha` — the diagnostic blocks name the sha that was actually polled,
1765
1701
  // so a "never dispatched" hint points at a sha `preview_status` knows about.
1766
- const noChanges = patchEntries.length === 0;
1702
+ const noChanges = changedEntries.length === 0;
1767
1703
  reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit: statusSha, ref, verb, noChanges });
1768
- emitJson(args, buildJsonResult({ ok: status?.status !== "failed", ref, commit, changeId, candidate, status, previewPrUrl, noChanges }));
1769
- return status?.status === "failed" ? 1 : 0;
1704
+ const ok = previewSubmitSucceeded(status);
1705
+ emitJson(args, buildJsonResult({ ok, ref, commit, changeId, candidate, status, previewPrUrl, noChanges }));
1706
+ return ok ? 0 : 1;
1770
1707
  } catch (e) {
1771
1708
  progress?.stop();
1772
- if (e instanceof CandidateAttributionError) {
1709
+ if (e instanceof CandidateRegistrationError) {
1773
1710
  const note = `${e.message} Your preview push is in, but no review PR was created.`;
1774
- if (!args.json) console.error(fail(note, "follow the identity guidance above, then re-run `tot submit`"));
1711
+ if (!args.json) console.error(fail(note, `re-run \`tot submit\`; retry will reuse ${ref}`));
1775
1712
  emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
1776
1713
  return 1;
1777
1714
  }
1778
1715
  const note = e instanceof AuthUnavailableError
1779
- ? `sign in to see the reconcile/compliance/preview result ${e.hint || "developer sign-in pending"}`
1780
- : `reconcile is running — the result read-back isn't available yet: ${describeReadbackError(e)}`;
1781
- if (!args.json) {
1782
- console.log(` (${note})`);
1783
- console.log(` Your push is in; the preview updates once reconcile completes.`);
1784
- }
1785
- emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
1786
- return 0;
1716
+ ? `Preview result unavailable: ${e.hint || "developer sign-in is required"}.`
1717
+ : `Preview result unavailable: ${describeReadbackError(e)}.`;
1718
+ if (!args.json) console.error(fail(note, `the candidate ref remains at ${ref}; fix the error and re-run`));
1719
+ emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
1720
+ return 1;
1787
1721
  }
1788
1722
  }
1789
1723
 
@@ -1819,6 +1753,12 @@ export function normalizePreviewStatus(r) {
1819
1753
  };
1820
1754
  }
1821
1755
 
1756
+ /** A submit succeeds only when reconcile has neither failed nor failed to start. */
1757
+ export function previewSubmitSucceeded(status) {
1758
+ const knownSuccessState = status?.status === "pending" || status?.status === "reconciled";
1759
+ return knownSuccessState && !status?.notDispatched && !status?.forwardFailed;
1760
+ }
1761
+
1822
1762
  /**
1823
1763
  * Poll `preview_status` while reconcile is pending (and, with untilShipped, while
1824
1764
  * reconciled-but-not-yet-shipped). E2: every call carries `waitMs` so a
@@ -2026,7 +1966,7 @@ export function formatNotDispatchedBlock({ commit = null, ref = null, noChanges
2026
1966
  // down two wrong debugging paths).
2027
1967
  const causes = [
2028
1968
  ...(noChanges
2029
- ? [` • your submit contained NO content changes — a candidate with no diff opens no PR and builds nothing (make an edit, or move the shared ref: \`tot ${verb} --ref preview\`),`]
1969
+ ? [` • your submit contained NO content changes — a candidate with no diff opens no PR and builds nothing (make and commit an edit, then re-run \`tot ${verb}\`),`]
2030
1970
  : []),
2031
1971
  ` • the store's reconcile webhook isn't registered yet (an operator must (re-)provision it), or`,
2032
1972
  ` • your session is scoped to a different store than the one you pushed.`,